Consolidate data access control backoffice updates

This commit is contained in:
devmrko
2026-07-13 23:06:23 +09:00
parent 403298d474
commit e18b30feab
181 changed files with 11571 additions and 954 deletions

View File

@@ -11,7 +11,41 @@ public record BackofficeProperties(
Ai ai
) {
public record Security(String adminUser, String adminPassword, boolean requireHttps) {
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) {
@@ -22,15 +56,20 @@ public record BackofficeProperties(
public record Ai(
boolean enabled,
String provider,
String baseUrl,
String model,
String apiKey,
Duration timeout,
String embeddingModel
String embeddingModel,
String ociConfigFile,
String ociProfile,
String ociRegion,
String ociCompartmentId
) {
public Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
this(enabled, baseUrl, model, apiKey, timeout, "");
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
}
}
}

View File

@@ -1,71 +0,0 @@
package com.cloudhandson.vpdbackoffice.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Authenticates only the streamable HTTP MCP endpoint with a service token.
* Business-data authorization remains the bearerToken tool argument, which is
* resolved to the VPD context by the ORDS handler.
*/
@Component
public class McpAccessTokenFilter extends OncePerRequestFilter {
private final String accessToken;
public McpAccessTokenFilter(@Value("${backoffice.mcp.access-token:}") String accessToken) {
this.accessToken = accessToken == null ? "" : accessToken.trim();
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !"/mcp".equals(request.getRequestURI());
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String bearerToken = bearerToken(request.getHeader("Authorization"));
if (!accessToken.isBlank() && bearerToken != null && constantTimeEquals(accessToken, bearerToken)) {
var authentication = new UsernamePasswordAuthenticationToken(
"mcp-client",
null,
List.of(new SimpleGrantedAuthority("ROLE_MCP"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String bearerToken(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
return null;
}
String value = authorization.substring(7).trim();
return value.isEmpty() ? null : value;
}
private boolean constantTimeEquals(String expected, String actual) {
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
actual.getBytes(StandardCharsets.UTF_8)
);
}
}

View File

@@ -1,7 +1,6 @@
package com.cloudhandson.vpdbackoffice.config;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -20,7 +19,6 @@ public class OrdsClientConfig {
}
@Bean
@Qualifier("ordsAgentRestTemplate")
RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) {
Duration timeout = properties.ords().agentTimeout();
return new RestTemplateBuilder()

View File

@@ -1,16 +1,18 @@
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.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.http.HttpMethod;
@Configuration
public class SecurityConfig {
@@ -19,23 +21,48 @@ public class SecurityConfig {
SecurityFilterChain securityFilterChain(
HttpSecurity http,
BackofficeProperties properties,
McpAccessTokenFilter mcpAccessTokenFilter
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
.addFilterBefore(mcpAccessTokenFilter, BasicAuthenticationFilter.class)
.csrf(csrf -> csrf.ignoringRequestMatchers(
"/mcp", "/mcp/messages", "/mcp/*/messages", "/dds/mcp/messages"))
"/mcp", "/mcp/messages", "/mcp/*/messages"))
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/webjars/**", "/dds/mcp/sse", "/dds/mcp/messages")
.requestMatchers(
"/css/**", "/js/**", "/webjars/**",
"/mcp", "/mcp/sse", "/mcp/*/sse", "/mcp/messages", "/mcp/*/messages")
.permitAll()
.requestMatchers(HttpMethod.POST, "/mcp").hasAnyRole("ADMIN", "MCP")
.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())
.httpBasic(basic -> {
})
@@ -52,11 +79,26 @@ public class SecurityConfig {
PasswordEncoder passwordEncoder
) {
var security = properties.security();
var user = User.withUsername(security.adminUser())
.password(passwordEncoder.encode(security.adminPassword()))
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())
// BCrypt encoding includes a random salt. Re-encoding the configured
// password on every startup would invalidate remember-me signatures.
.password(encodedPassword)
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
.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