@@ -0,0 +1,23 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import java.time.Clock;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({
|
||||
BackofficeProperties.class,
|
||||
CatalogProperties.class,
|
||||
MaskingProperties.class,
|
||||
McpProperties.class,
|
||||
ProductProperties.class,
|
||||
SecuritySqlScriptProperties.class
|
||||
})
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "backoffice")
|
||||
public record BackofficeProperties(
|
||||
Security security,
|
||||
Token token,
|
||||
Ords ords,
|
||||
Ai ai,
|
||||
SelectAi selectAi
|
||||
) {
|
||||
|
||||
public record Security(
|
||||
String adminUser,
|
||||
String adminPassword,
|
||||
String adminPasswordHash,
|
||||
boolean guestEnabled,
|
||||
String guestUser,
|
||||
String guestPassword,
|
||||
String guestPasswordHash,
|
||||
boolean requireHttps,
|
||||
boolean rememberMeEnabled,
|
||||
String rememberMeKey,
|
||||
int rememberMeDays
|
||||
) {
|
||||
|
||||
/** Remember-me is intentionally unavailable over HTTP or without a stable secret key. */
|
||||
public boolean rememberMeConfigured() {
|
||||
return requireHttps
|
||||
&& rememberMeEnabled
|
||||
&& rememberMeKey != null
|
||||
&& !rememberMeKey.isBlank()
|
||||
&& rememberMeDays >= 1
|
||||
&& rememberMeDays <= 90;
|
||||
}
|
||||
|
||||
public int rememberMeValiditySeconds() {
|
||||
return rememberMeDays * 24 * 60 * 60;
|
||||
}
|
||||
|
||||
public boolean guestConfigured() {
|
||||
return guestEnabled
|
||||
&& guestUser != null
|
||||
&& !guestUser.isBlank()
|
||||
&& ((guestPassword != null && !guestPassword.isBlank())
|
||||
|| (guestPasswordHash != null && !guestPasswordHash.isBlank()));
|
||||
}
|
||||
}
|
||||
|
||||
public record Token(int maxDays) {
|
||||
}
|
||||
|
||||
public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) {
|
||||
}
|
||||
|
||||
public record Ai(
|
||||
boolean enabled,
|
||||
String provider,
|
||||
String baseUrl,
|
||||
String model,
|
||||
String apiKey,
|
||||
Duration timeout,
|
||||
String embeddingModel,
|
||||
String ociConfigFile,
|
||||
String ociProfile,
|
||||
String ociRegion,
|
||||
String ociCompartmentId
|
||||
) {
|
||||
|
||||
public Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
|
||||
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
|
||||
}
|
||||
}
|
||||
|
||||
/** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */
|
||||
public record SelectAi(
|
||||
String dbUrl,
|
||||
String dbUsername,
|
||||
String dbPassword,
|
||||
String profile,
|
||||
String runtimeDbUrl,
|
||||
String runtimeDbUsername,
|
||||
String runtimeDbPassword,
|
||||
String queryContractFile
|
||||
) {
|
||||
|
||||
public boolean configured() {
|
||||
return dbUrl != null && !dbUrl.isBlank()
|
||||
&& dbUsername != null && !dbUsername.isBlank()
|
||||
&& dbPassword != null && !dbPassword.isBlank()
|
||||
&& profile != null && !profile.isBlank();
|
||||
}
|
||||
|
||||
/** The generated SQL must never fall back to the privileged profile-owner connection. */
|
||||
public boolean runtimeConfigured() {
|
||||
return runtimeDbUrl != null && !runtimeDbUrl.isBlank()
|
||||
&& runtimeDbUsername != null && !runtimeDbUsername.isBlank()
|
||||
&& runtimeDbPassword != null && !runtimeDbPassword.isBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Deployment-provided allow-list for the structured-data and metadata screens. */
|
||||
@ConfigurationProperties(prefix = "backoffice.catalog")
|
||||
public record CatalogProperties(String owner, String objects) {
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.PermissionService;
|
||||
import com.cloudhandson.vpdbackoffice.service.GroupService;
|
||||
import com.cloudhandson.vpdbackoffice.service.UserService;
|
||||
import java.sql.Connection;
|
||||
import javax.sql.DataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class DbPoolWarmup {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DbPoolWarmup.class);
|
||||
private final DataSource dataSource;
|
||||
private final UserService userService;
|
||||
private final GroupService groupService;
|
||||
private final PermissionService permissionService;
|
||||
|
||||
public DbPoolWarmup(
|
||||
DataSource dataSource,
|
||||
UserService userService,
|
||||
GroupService groupService,
|
||||
PermissionService permissionService
|
||||
) {
|
||||
this.dataSource = dataSource;
|
||||
this.userService = userService;
|
||||
this.groupService = groupService;
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void warmup() {
|
||||
try (Connection ignored = dataSource.getConnection()) {
|
||||
log.info("Backoffice DB pool warmed up");
|
||||
warmupBackofficeCatalog();
|
||||
} catch (Exception exception) {
|
||||
log.warn("Backoffice DB pool warm-up failed: {}", exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void warmupBackofficeCatalog() {
|
||||
long started = System.nanoTime();
|
||||
userService.findAll();
|
||||
userService.findUserRoles();
|
||||
groupService.findAll();
|
||||
groupService.findGroupUsers();
|
||||
groupService.findGroupRoles();
|
||||
permissionService.findRoles();
|
||||
permissionService.findPermissionViews();
|
||||
log.info("HMM identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** JSON configuration of database redaction policies this backoffice may manage. */
|
||||
@ConfigurationProperties(prefix = "backoffice.masking")
|
||||
public record MaskingProperties(String policies) {
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Product-neutral MCP endpoint labels and tool catalogue configuration. */
|
||||
@ConfigurationProperties(prefix = "backoffice.mcp")
|
||||
public record McpProperties(
|
||||
String publicUrl,
|
||||
String serverName,
|
||||
String toolName,
|
||||
String toolLabel,
|
||||
String toolDescription,
|
||||
String promptDescription,
|
||||
String tools
|
||||
) {
|
||||
|
||||
private static final String DEFAULT_PUBLIC_URL = "/mcp";
|
||||
private static final String DEFAULT_SERVER_NAME = "data-ai-backoffice";
|
||||
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
|
||||
private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL";
|
||||
private static final String DEFAULT_TOOL_DESCRIPTION =
|
||||
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 "
|
||||
+ "트랜잭션에서 실행합니다.";
|
||||
private static final String DEFAULT_PROMPT_DESCRIPTION =
|
||||
"업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
|
||||
|
||||
public String resolvedPublicUrl() {
|
||||
return requiredOrDefault(publicUrl, DEFAULT_PUBLIC_URL);
|
||||
}
|
||||
|
||||
public String resolvedServerName() {
|
||||
return requiredOrDefault(serverName, DEFAULT_SERVER_NAME);
|
||||
}
|
||||
|
||||
public String resolvedToolName() {
|
||||
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
|
||||
}
|
||||
|
||||
public String resolvedToolLabel() {
|
||||
return requiredOrDefault(toolLabel, DEFAULT_TOOL_LABEL);
|
||||
}
|
||||
|
||||
public String resolvedToolDescription() {
|
||||
return requiredOrDefault(toolDescription, DEFAULT_TOOL_DESCRIPTION);
|
||||
}
|
||||
|
||||
public String resolvedPromptDescription() {
|
||||
return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION);
|
||||
}
|
||||
|
||||
private String requiredOrDefault(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Configuration
|
||||
public class OrdsClientConfig {
|
||||
|
||||
@Bean
|
||||
RestTemplate ordsRestTemplate(BackofficeProperties properties) {
|
||||
Duration timeout = properties.ords().timeout();
|
||||
return new RestTemplateBuilder()
|
||||
.setConnectTimeout(timeout)
|
||||
.setReadTimeout(timeout)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) {
|
||||
Duration timeout = properties.ords().agentTimeout();
|
||||
return new RestTemplateBuilder()
|
||||
.setConnectTimeout(timeout)
|
||||
.setReadTimeout(timeout)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Customer-facing labels that do not affect authorization or database identity. */
|
||||
@ConfigurationProperties(prefix = "backoffice.product")
|
||||
public record ProductProperties(String name, String title, String dataLabel) {
|
||||
|
||||
public String displayName() {
|
||||
return name == null || name.isBlank() ? "Data & AI Backoffice" : name.trim();
|
||||
}
|
||||
|
||||
public String pageTitle() {
|
||||
return title == null || title.isBlank() ? displayName() : title.trim();
|
||||
}
|
||||
|
||||
public String dataName() {
|
||||
return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
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;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
BackofficeProperties properties,
|
||||
UserDetailsService userDetailsService
|
||||
) throws Exception {
|
||||
if (properties.security().requireHttps()) {
|
||||
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
|
||||
}
|
||||
|
||||
var security = properties.security();
|
||||
if (security.rememberMeConfigured()) {
|
||||
http.rememberMe(rememberMe -> rememberMe
|
||||
.key(security.rememberMeKey())
|
||||
.userDetailsService(userDetailsService)
|
||||
.rememberMeParameter("remember-me")
|
||||
.rememberMeCookieName("VPD_REMEMBER_ME")
|
||||
.tokenValiditySeconds(security.rememberMeValiditySeconds())
|
||||
.useSecureCookie(true)
|
||||
.alwaysRemember(false));
|
||||
}
|
||||
|
||||
return http
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers(
|
||||
"/mcp", "/mcp/messages", "/mcp/*/messages"))
|
||||
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
||||
.includeSubDomains(true)
|
||||
.maxAgeInSeconds(31_536_000)))
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/css/**", "/js/**", "/webjars/**",
|
||||
"/mcp", "/mcp/sse", "/mcp/*/sse", "/mcp/messages", "/mcp/*/messages")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/login", "/logout").permitAll()
|
||||
.requestMatchers(HttpMethod.POST,
|
||||
"/probe",
|
||||
"/vector-knowledge/search",
|
||||
"/security-sql-scripts/explanation",
|
||||
"/mcp-chatbot",
|
||||
"/mcp-client-demo",
|
||||
"/mcp-reasoning")
|
||||
.authenticated()
|
||||
.requestMatchers(HttpMethod.PUT, "/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.PATCH, "/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.DELETE, "/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.formLogin(login -> login
|
||||
.loginPage("/login")
|
||||
.permitAll())
|
||||
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService(
|
||||
BackofficeProperties properties,
|
||||
PasswordEncoder passwordEncoder
|
||||
) {
|
||||
var security = properties.security();
|
||||
String encodedPassword = security.adminPasswordHash() == null || security.adminPasswordHash().isBlank()
|
||||
? passwordEncoder.encode(security.adminPassword())
|
||||
: security.adminPasswordHash();
|
||||
List<UserDetails> users = new ArrayList<>();
|
||||
users.add(User.withUsername(security.adminUser())
|
||||
.password(encodedPassword)
|
||||
.roles("ADMIN")
|
||||
.build());
|
||||
if (security.guestConfigured()) {
|
||||
String encodedGuestPassword = security.guestPasswordHash() == null || security.guestPasswordHash().isBlank()
|
||||
? passwordEncoder.encode(security.guestPassword())
|
||||
: security.guestPasswordHash();
|
||||
users.add(User.withUsername(security.guestUser())
|
||||
.password(encodedGuestPassword)
|
||||
.roles("VIEWER")
|
||||
.build());
|
||||
}
|
||||
return new InMemoryUserDetailsManager(users);
|
||||
}
|
||||
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() {
|
||||
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Deployment-provided allow-list for bundled security SQL shown by the backoffice. */
|
||||
@ConfigurationProperties(prefix = "backoffice.security-sql-scripts")
|
||||
public record SecuritySqlScriptProperties(String scripts) {
|
||||
}
|
||||
Reference in New Issue
Block a user