refs #742: merge HMM application layout into main

# Conflicts:
#	src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java
#	vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java
#	vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java
#	vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml
#	vpd-backoffice/src/main/resources/templates/schema-metadata.html
#	vpd-backoffice/src/main/resources/templates/structured-data.html
This commit is contained in:
devmrko
2026-08-03 10:38:25 +09:00
465 changed files with 10052 additions and 3579 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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