Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/security/etl-service-jwt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# ETL service JWT authentication

**Capability status:** `active_pr` on PR #287; not `implemented_on_develop` until protected integration.
**Protected baseline assessed:** `develop@d6c6665163eabe1b5eca80556c6963bafd6b2625`
**Security boundary:** direct and gateway-routed access to the independently runnable `etl-service`

## Decision

The historical HTTP Basic mechanism is removed from the ETL service security chain. The repository default is a credential-free, fail-closed `deny` posture: actuator health and information probes remain available, but `/api/**` returns `401 Unauthorized` until a deployment explicitly selects JWT mode and supplies issuer and audience authority.

JWT mode uses Spring Security's maintained OAuth 2.0 Resource Server support. The service validates bearer tokens independently rather than trusting a gateway header as an authenticated downstream principal. Direct access on the published ETL port and access routed through the gateway therefore cross the same service-owned authentication boundary.

## Configuration contract

The mode is selected through the following precedence:

```text
mightyetl.security.mode
→ xtrmetl.security.mode
→ ETL_SECURITY_MODE
→ deny
```

Supported values are:

| Value | Behavior |
|---|---|
| `deny` | Secure default. `/api/**` is unavailable and historical Basic credentials are rejected. |
| `jwt` | OAuth 2.0 Resource Server bearer-token validation is enabled. |

Unknown values fail during security-chain construction. JWT mode additionally requires nonblank deployment-owned values for:

```text
spring.security.oauth2.resourceserver.jwt.issuer-uri
spring.security.oauth2.resourceserver.jwt.audiences
```

Spring Boot environment-variable forms can be used by deployment systems:

```text
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_AUDIENCES
```

No issuer, JWK endpoint, audience, client secret, certificate authority, token, or compatibility password is invented or committed by mightyETL. Deployment configuration and secret-management systems own those values.

## Validation and principal contract

Spring Security obtains issuer metadata and signing-key authority from the configured issuer according to its Resource Server JWT support. It validates the token signature and standard time and issuer claims; Spring Boot's configured audience authority prevents a token issued for another resource from being accepted by the ETL service.

The authenticated principal name is the validated JWT subject (`sub`) under Spring Security's default JWT authentication mapping. Existing idempotency and durable-job owner scoping therefore consume a validated service principal rather than an untrusted forwarded username or a Basic-auth account. Changing subject semantics requires a separately versioned identity-migration decision because it can change access to previously scoped records.

The gateway may forward the original bearer token to the ETL service, but gateway validation never substitutes for ETL validation. A future distinct gateway-to-service credential, mTLS profile, or service mesh identity must be introduced through a separate reviewed contract rather than by trusting arbitrary caller headers.

## Failure and degraded behavior

- Missing or blank mode selects `deny`.
- Unknown mode fails closed during startup.
- JWT mode with missing issuer or audience authority fails during startup.
- Missing, malformed, expired, wrong-issuer, wrong-audience, or unverifiable bearer tokens cannot reach ETL controllers.
- HTTP Basic, form login, logout, server-side sessions, and request caching are disabled.
- Health and information probes remain available for orchestration.
- Issuer or JWK unavailability follows Spring Security's maintained decoder and key-cache behavior; deployments must monitor readiness and authentication failures rather than silently falling back to Basic.

Authentication failures must retain bounded classifications without logging bearer tokens, Authorization headers, JWK material, full provider exceptions, credentials, or raw request payloads. This is purpose-bound diagnostic minimization, not blanket masking of business data needed by authorized ETL work.

## Rollout and rollback

1. Provision an approved issuer and ETL-specific audience.
2. Configure and validate JWT mode in a non-production environment.
3. Verify direct-port and gateway-routed calls with valid, invalid, expired, wrong-issuer, and wrong-audience tokens.
4. Verify that the resulting `sub` preserves intended idempotency and durable-job ownership.
5. Remove Basic credentials from clients and secret stores.
6. Enable production JWT mode and monitor bounded authentication outcomes.

Rollback to HTTP Basic is prohibited because it restores the vulnerability that this change removes. If issuer authority is unavailable, the safe rollback is `deny` mode while identity infrastructure is restored or a separately reviewed authentication mechanism is deployed. The service must not silently downgrade authentication to maintain availability.

## Machine-readable API handoff

PR #278 currently describes the protected runtime truth with an OpenAPI HTTP Basic scheme, and Semgrep correctly rejects that weak mechanism. Do not suppress or falsify the contract on the authentication branch. After this runtime change integrates, refresh the machine-readable contract from protected `develop`, replace the Basic scheme with bearer JWT semantics, regenerate SAST evidence, and preserve exact source/review lineage. The SAST finding is resolved only when runtime and contract move together.

## Testing and evidence

The current branch preserves hosted RED evidence showing that valid historical Basic credentials reached a protected endpoint. The GREEN contract exercises:

- default-deny behavior through the real Spring Security filter chain;
- rejection of valid historical Basic credentials;
- rejection of missing and invalid bearer tokens;
- successful JWT subject authentication through the real bearer-token filter;
- fail-closed mode parsing;
- required issuer and audience authority;
- unchanged health/information probe intent;
- complete current-head CI, dependency, SBOM, SAST, security, and review evidence before integration.

## References (APA 7th)

Internet Engineering Task Force. (2012). *The OAuth 2.0 authorization framework: Bearer token usage* (RFC 6750). https://www.rfc-editor.org/rfc/rfc6750

Internet Engineering Task Force. (2015). *JSON Web Token (JWT)* (RFC 7519). https://www.rfc-editor.org/rfc/rfc7519

Internet Engineering Task Force. (2018). *OAuth 2.0 authorization server metadata* (RFC 8414). https://www.rfc-editor.org/rfc/rfc8414

Internet Engineering Task Force. (2021). *JSON Web Token (JWT) profile for OAuth 2.0 access tokens* (RFC 9068). https://www.rfc-editor.org/rfc/rfc9068

Internet Engineering Task Force. (2025). *Best current practice for OAuth 2.0 security* (RFC 9700, BCP 240). https://www.rfc-editor.org/rfc/rfc9700

Spring Security. (2026). *OAuth 2.0 resource server JWT*. https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html

Spring Boot. (2026). *OAuth2 resource server*. https://docs.spring.io/spring-boot/reference/web/spring-security.html#web.security.oauth2.server
4 changes: 4 additions & 0 deletions etl-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.xtrmetl.etl.security;

import java.util.Locale;

/**
* Supported ETL service authentication postures.
*
* <p>{@link #DENY} is the credential-free secure default. {@link #JWT} enables deployment-owned
* OAuth 2.0 Resource Server validation.</p>
*/
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
);
};
}
}
106 changes: 92 additions & 14 deletions etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*/
@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 인증 활성화.
* <p>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/**}.</p>
*
* @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
);
}
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading