{@link #DENY} is the credential-free secure default. {@link #JWT} enables deployment-owned + * OAuth 2.0 Resource Server validation.
+ */ +enum EtlSecurityMode { + DENY, + JWT; + + /** + * Converts external configuration into a fail-closed security mode. + * + * @param rawMode configured mode; null and blank values select {@link #DENY} + * @return supported security mode + * @throws IllegalArgumentException when the configured value is not supported + */ + static EtlSecurityMode parse(String rawMode) { + if (rawMode == null || rawMode.isBlank()) { + return DENY; + } + return switch (rawMode.trim().toLowerCase(Locale.ROOT)) { + case "deny" -> DENY; + case "jwt" -> JWT; + default -> throw new IllegalArgumentException( + "Unsupported mightyETL security mode: " + rawMode + ); + }; + } +} diff --git a/etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java b/etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java index a93dd907..c5aa7c30 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java @@ -1,34 +1,112 @@ package com.xtrmetl.etl.security; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.HttpStatusAccessDeniedHandler; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +/** + * Configures the independently enforced HTTP authentication boundary for the ETL service. + * + *The secure repository default is {@code deny}: health and information probes remain + * available, while direct workload APIs are unavailable until the deployment explicitly selects + * JWT mode and supplies issuer and audience authority. Historical HTTP Basic credentials are never + * accepted by this configuration.
+ */ @Configuration -@EnableWebSecurity public class SecurityConfig { + private static final String JWT_ISSUER_PROPERTY = + "spring.security.oauth2.resourceserver.jwt.issuer-uri"; + private static final String JWT_AUDIENCES_PROPERTY = + "spring.security.oauth2.resourceserver.jwt.audiences"; + private static final String[] PUBLIC_ENDPOINTS = { + "/actuator/health", + "/actuator/health/**", + "/actuator/info" + }; + /** - * 애플리케이션의 HTTP 보안 필터 체인을 구성한다. + * Builds the service security filter chain from deployment-owned authentication settings. * - * 구성 내용: CSRF 비활성화, "/api/**" 경로에 대해 인증 요구, 다른 모든 요청 허용, HTTP Basic 인증 활성화. + *In {@code jwt} mode Spring Security validates bearer tokens through its maintained OAuth + * 2.0 Resource Server support. The configured issuer supplies key and issuer authority, while + * the configured audience prevents a token minted for another service from being accepted by + * mightyETL. In the default {@code deny} mode no credential can reach {@code /api/**}.
* - * @param http 구성에 사용되는 HttpSecurity 인스턴스 - * @return 구성된 SecurityFilterChain 인스턴스 - * @throws Exception 보안 구성을 적용하는 동안 오류가 발생한 경우 + * @param http Spring Security HTTP configuration to build + * @param configuredMode explicit mightyETL security mode, defaulting to {@code deny} + * @param environment deployment properties used to verify JWT trust authority + * @return configured stateless security filter chain + * @throws Exception when Spring Security cannot build the filter chain + * @throws IllegalArgumentException when the mode is unknown or JWT trust settings are missing */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + @Value("${mightyetl.security.mode:${xtrmetl.security.mode:${ETL_SECURITY_MODE:deny}}}") + String configuredMode, + Environment environment + ) throws Exception { + EtlSecurityMode securityMode = EtlSecurityMode.parse(configuredMode); + http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/**").authenticated() - .anyRequest().permitAll() - ) - .httpBasic(Customizer.withDefaults()); + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .requestCache(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + if (securityMode == EtlSecurityMode.JWT) { + requireJwtTrustConfiguration(environment); + http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll()) + .oauth2ResourceServer(resourceServer -> + resourceServer.jwt(Customizer.withDefaults())); + } else { + http + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler(new HttpStatusAccessDeniedHandler(HttpStatus.UNAUTHORIZED))) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers("/api/**").denyAll() + .anyRequest().permitAll()); + } + return http.build(); } + + /** + * Requires the deployment authority needed for issuer and audience validation in JWT mode. + * + * @param environment deployment property source + * @throws IllegalArgumentException when issuer or audience authority is missing + */ + static void requireJwtTrustConfiguration(Environment environment) { + requireNonBlank(environment, JWT_ISSUER_PROPERTY); + requireNonBlank(environment, JWT_AUDIENCES_PROPERTY); + } + + private static void requireNonBlank(Environment environment, String propertyName) { + String value = environment.getProperty(propertyName); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException( + "JWT security mode requires non-blank property: " + propertyName + ); + } + } } diff --git a/etl-service/src/test/java/com/xtrmetl/etl/security/EtlSecurityModeTest.java b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlSecurityModeTest.java new file mode 100644 index 00000000..61cc6dd0 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlSecurityModeTest.java @@ -0,0 +1,75 @@ +package com.xtrmetl.etl.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies fail-closed parsing and deployment-authority requirements for ETL security modes. + */ +class EtlSecurityModeTest { + + @Test + void absentAndExplicitDenyConfigurationSelectTheSecureDefault() { + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse(null)); + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse("")); + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse(" ")); + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse("deny")); + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse(" DENY ")); + } + + @Test + void jwtConfigurationIsCaseInsensitiveButUnknownModesFailClosed() { + assertEquals(EtlSecurityMode.JWT, EtlSecurityMode.parse("jwt")); + assertEquals(EtlSecurityMode.JWT, EtlSecurityMode.parse(" JWT ")); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> EtlSecurityMode.parse("basic") + ); + assertTrue(failure.getMessage().contains("Unsupported mightyETL security mode")); + } + + @Test + void jwtModeRequiresDeploymentOwnedIssuerAuthority() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.security.oauth2.resourceserver.jwt.audiences", "mightyetl-etl"); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> SecurityConfig.requireJwtTrustConfiguration(environment) + ); + assertTrue(failure.getMessage().contains("issuer-uri")); + } + + @Test + void jwtModeRequiresDeploymentOwnedAudienceAuthority() { + MockEnvironment environment = new MockEnvironment() + .withProperty( + "spring.security.oauth2.resourceserver.jwt.issuer-uri", + "https://issuer.example.invalid" + ); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> SecurityConfig.requireJwtTrustConfiguration(environment) + ); + assertTrue(failure.getMessage().contains("audiences")); + } + + @Test + void completeJwtTrustAuthorityPassesThePreflight() { + MockEnvironment environment = new MockEnvironment() + .withProperty( + "spring.security.oauth2.resourceserver.jwt.issuer-uri", + "https://issuer.example.invalid" + ) + .withProperty("spring.security.oauth2.resourceserver.jwt.audiences", "mightyetl-etl"); + + assertDoesNotThrow(() -> SecurityConfig.requireJwtTrustConfiguration(environment)); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceAuthenticationModeTest.java b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceAuthenticationModeTest.java new file mode 100644 index 00000000..97683bf8 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceAuthenticationModeTest.java @@ -0,0 +1,92 @@ +package com.xtrmetl.etl.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.security.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.security.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * Exercises the registered ETL {@link SecurityConfig} default-deny posture. + * + *A valid user known only to the historical HTTP Basic mechanism must not regain access to + * {@code /api/**}. Deployments must explicitly select JWT mode and provide issuer and audience + * authority before workload endpoints can be reached.
+ */ +@SpringJUnitConfig(classes = { + SecurityConfig.class, + EtlServiceAuthenticationModeTest.TestApplication.class +}) +@WebAppConfiguration +class EtlServiceAuthenticationModeTest { + + private static final String TEST_USERNAME = "contract-user"; + private static final String TEST_PASSWORD = "contract-secret"; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + void protectedApiRejectsHistoricalBasicCredentials() throws Exception { + mockMvc.perform(get("/api/security-contract")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(get("/api/security-contract") + .with(httpBasic(TEST_USERNAME, TEST_PASSWORD))) + .andExpect(status().isUnauthorized()); + } + + @Configuration + @EnableWebMvc + static class TestApplication { + + @Bean + UserDetailsService testUsers() { + return new InMemoryUserDetailsManager( + User.withUsername(TEST_USERNAME) + .password("{noop}" + TEST_PASSWORD) + .roles("TEST") + .build() + ); + } + + @Bean + SecurityContractController securityContractController() { + return new SecurityContractController(); + } + } + + @RestController + static class SecurityContractController { + + @GetMapping("/api/security-contract") + String protectedEndpoint() { + return "ok"; + } + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceJwtAuthenticationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceJwtAuthenticationTest.java new file mode 100644 index 00000000..9a6504fb --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceJwtAuthenticationTest.java @@ -0,0 +1,123 @@ +package com.xtrmetl.etl.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import java.time.Instant; +import java.util.List; + +import static org.hamcrest.Matchers.startsWith; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.security.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.security.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.security.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.security.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * Exercises mightyETL JWT mode through the real Spring Security bearer-token filter chain. + */ +@SpringJUnitConfig(classes = { + SecurityConfig.class, + EtlServiceJwtAuthenticationTest.TestApplication.class +}) +@TestPropertySource(properties = { + "mightyetl.security.mode=jwt", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=https://issuer.example.invalid", + "spring.security.oauth2.resourceserver.jwt.audiences=mightyetl-etl" +}) +@WebAppConfiguration +class EtlServiceJwtAuthenticationTest { + + private static final String ACCEPTED_TOKEN = "accepted-token"; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + void jwtModeRejectsMissingInvalidAndHistoricalBasicCredentials() throws Exception { + mockMvc.perform(get("/api/security-contract")) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer"))); + + mockMvc.perform(get("/api/security-contract") + .header(HttpHeaders.AUTHORIZATION, "Bearer rejected-token")) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer"))); + + mockMvc.perform(get("/api/security-contract") + .with(httpBasic("historical-user", "historical-secret"))) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer"))); + } + + @Test + void jwtModeAuthenticatesTheValidatedSubject() throws Exception { + mockMvc.perform(get("/api/security-contract") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + ACCEPTED_TOKEN)) + .andExpect(status().isOk()) + .andExpect(content().string("contract-user")); + } + + @Configuration + @EnableWebMvc + static class TestApplication { + + @Bean + JwtDecoder jwtDecoder() { + return token -> { + if (!ACCEPTED_TOKEN.equals(token)) { + throw new BadJwtException("Rejected test token"); + } + Instant issuedAt = Instant.parse("2026-08-15T00:00:00Z"); + return Jwt.withTokenValue(token) + .header("alg", "RS256") + .subject("contract-user") + .audience(List.of("mightyetl-etl")) + .issuedAt(issuedAt) + .expiresAt(issuedAt.plusSeconds(3600)) + .build(); + }; + } + + @Bean + SecurityContractController securityContractController() { + return new SecurityContractController(); + } + } + + @RestController + static class SecurityContractController { + + @GetMapping("/api/security-contract") + String protectedEndpoint(Authentication authentication) { + return authentication.getName(); + } + } +}