chore: record pre-existing uncommitted repository state

Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-11 16:48:43 +09:00
co-authored by Claude Opus 5
parent 1a3b560678
commit 5f10b791d3
1857 changed files with 130925 additions and 72491 deletions
+27 -7
View File
@@ -20,12 +20,16 @@ Package root: `dev.caskeleton.adapter.inbound.graphql`.
자동 합성/바인딩하도록 얹는 얇은 계층이다.
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
- classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를
의존하지 않는다. 실제 채택 시 composition root 가 GraphQL leaf 와 인증/인가·CORS 정책,
GraphiQL/introspection 운영 설정을 함께 명시해야 한다.
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`.
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
- test scope 에 한해 실제 HTTP 인증/CORS qualification 용 `spring-boot-starter-security`.
## Forbidden
@@ -43,18 +47,34 @@ feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를
`GraphqlExceptionResolver``GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다.
비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md).
## Feature 기여 방법
## 향후 adopter 의 feature 기여 방법
- **스키마**: `src/main/resources/graphql/*.graphqls` 를 두면 `classpath:graphql/**` 병합으로 합쳐진다.
- **핸들러**: `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동 바인딩된다.
- **도메인 예외 매핑**: sample 이 자신의 `DataFetcherExceptionResolver` 를 추가해 도메인 예외를
`PortfolioErrorCode` 로 매핑한다(스켈레톤 resolver 보다 앞 순서). 스켈레톤은 `ApiErrorCarrier` 만 처리.
- **스키마**: adopter feature 가 `src/main/resources/graphql/*.graphqls` 를 두면
`classpath:graphql/**` 병합으로 합칠 수 있다.
- **핸들러**: adopter 가 `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동
바인딩된다.
- **도메인 예외 매핑**: adopter 는 자신의 `DataFetcherExceptionResolver` 를 추가하거나
`ApiErrorCarrier` 를 사용해 안정적 코드로 매핑할 수 있다.
`sample-portfolio` 를 지워도 스켈레톤은 health 스키마만으로 부팅한다 (disposability).
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
health 스키마만 소유한다.
## 명시적 미구현 범위(P2)
- feature GraphQL schema/resolver
- query depth/cost 제한
- persisted operation
- DataLoader/batching
- subscription
이 범위는 production GraphQL 표면 채택 시 별도 설계와 qualification 을 요구한다.
## Test
```bash
cd src
./gradlew :adapter:inbound:graphql:test
./gradlew :adapter:inbound:graphql:test --console=plain
./gradlew :adapter:inbound:graphql:test \
--tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \
--console=plain
```
+21 -5
View File
@@ -19,15 +19,18 @@ Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** Spring for GraphQL 이 두 축으로 자동 합성한다:
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** 향후 composition root 가 이 모듈을 classpath 에
명시적으로 채택하고 feature 를 추가하면 Spring for GraphQL 이 다음 두 축으로 합성할 수 있다:
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의
`worklog.graphqls` 스켈레톤의 `skeleton.graphqls` 자동으로 합쳐진다.
향후 `worklog.graphqls` 같은 feature 스키마는 스켈레톤의 `skeleton.graphqls` 와 합쳐진다.
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller``@QueryMapping`/`@MutationMapping`
메서드를 바인딩한다. sample 의 `WorkLogGraphqlController` 스켈레톤을 수정하지 않고 등록된다.
메서드를 바인딩한다. 향후 feature 의 GraphQL controller 스켈레톤을 수정하지 않고 등록할 수
있다.
`sample-portfolio` 를 지우면 스켈레톤은 여전히 health 스키마만으로 부팅한다(web 과 동일한
disposability 보장).
현재 `app-bootstrap``sample-portfolio` 의 production runtime 은 이 leaf 를 의존하지 않는다.
즉 이 모듈은 **classpath opt-in** 이며, 현재 sample 에 feature GraphQL 스키마/controller 가 있다는
뜻이 아니다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다.
## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제
@@ -74,3 +77,16 @@ gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
`GraphqlHttpBoundaryQualificationTest` 는 실제 random-port MVC HTTP 서버 위에서 test-only
SecurityFilterChain 과 CORS allowlist 를 조합해 인증, origin, GraphiQL 비활성화, introspection
비활성화, 오류 redaction 을 검증한다. 이 테스트 구성은 production 정책 bean 이 아니다. 실제
composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을 함께 제공하고
`spring.graphql.graphiql.enabled=false`,
`spring.graphql.schema.introspection.enabled=false` 를 운영 설정으로 명시해야 한다.
## 아직 구현하지 않은 P2 범위
이 leaf 와 현재 sample 에는 feature GraphQL schema/resolver, query depth/cost 제한, persisted
operation, DataLoader/batching, subscription 이 구현되어 있지 않다. 이 항목들은 실제 GraphQL 제품
표면을 채택할 때 별도 설계·테스트와 함께 추가해야 한다.
+16 -1
View File
@@ -3,13 +3,15 @@
// Spring for GraphQL is schema-first: schema files live in src/main/resources/graphql/*.graphqls
// and are merged from classpath:graphql/** at boot. This skeleton ships ONLY the minimal health
// schema + @Controller so the module boots standalone with zero features (an empty schema fails to
// start); feature schema/controllers live in the sample module and compose automatically.
// start); a future consuming feature can contribute schema/controllers that compose automatically.
//
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
// coordinates the BOM does not manage).
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
dependencies {
implementation project(':shared-contract')
@@ -20,4 +22,17 @@ dependencies {
// controller through a real AnnotatedControllerConfigurer and drives it with an
// ExecutionGraphQlServiceTester.
testImplementation 'org.springframework.boot:spring-boot-starter-graphql-test'
// The HTTP boundary qualification test boots a real random-port servlet server and supplies
// a test-only authentication/CORS composition. Security remains a composition-root concern;
// this dependency does not add production security policy to the opt-in GraphQL adapter.
testImplementation 'org.springframework.boot:spring-boot-starter-security'
}
registerStrictQualificationTest(
name: 'graphqlTransportQualificationTest',
sourceSet: sourceSets.test,
requiredClasses: [
'dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest'
],
description: 'Runs exact no-skip GraphQL conditional transport wire evidence.')
+7 -1
View File
@@ -95,7 +95,7 @@ org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
@@ -125,12 +125,14 @@ org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runti
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -148,6 +150,10 @@ org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,te
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -7,8 +7,8 @@ import org.springframework.stereotype.Controller;
* Minimal GraphQL health surface so the skeleton module boots standalone with zero features — the
* GraphQL sibling of the web adapter's {@code HealthcheckController}. The {@code _health} query
* resolves the {@code skeleton.graphqls} field of the same name to a fixed liveness token. Feature
* queries/mutations are contributed by the sample module's own {@code @Controller} beans and merged
* by Spring for GraphQL; this controller never names a feature type.
* queries/mutations may be contributed by a future consuming feature's {@code @Controller} beans
* and merged by Spring for GraphQL; this controller never names a feature type.
*/
@Controller
public class HealthGraphqlController {
@@ -1,9 +1,9 @@
# Minimal GraphQL schema for the skeleton machinery module (schema-first).
#
# Spring for GraphQL merges every classpath:graphql/**/*.graphqls file at boot, so this health
# schema composes automatically with any feature schema the sample module contributes. It exists so
# the module boots standalone with zero features: Spring for GraphQL refuses to start on an empty
# schema, and the skeleton must never name a feature type (mirrors web's HealthcheckController).
# schema can compose with schemas contributed by a future consuming feature. It exists so the module
# boots standalone with zero features: Spring for GraphQL refuses to start on an empty schema, and
# the skeleton must never name a feature type (mirrors web's HealthcheckController).
type Query {
"Liveness token for the GraphQL transport — mirrors the web adapter's /healthcheck."
_health: String!
@@ -74,6 +74,7 @@ class GraphqlExceptionResolverTest {
* Feature-style throwable carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
*/
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
private static final long serialVersionUID = 1L;
private final ApiErrorCode errorCode;
CarrierException(String code, Category category) {
@@ -0,0 +1,266 @@
package dev.caskeleton.adapter.inbound.graphql;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
import java.net.URI;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.stereotype.Controller;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Release qualification for the opt-in GraphQL adapter's real servlet HTTP boundary.
*
* <p>The nested application deliberately owns only test authentication and CORS policy. A real
* composition root must make those choices when it opts into this adapter; the adapter itself
* remains free of an unconditional production security policy.
*/
@SpringBootTest(
classes = GraphqlHttpBoundaryQualificationTest.TestApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"spring.graphql.graphiql.enabled=false",
"spring.graphql.schema.introspection.enabled=false",
"spring.graphql.schema.locations=classpath:graphql-qualification-no-discovery/",
"spring.graphql.schema.additional-files="
+ "classpath:graphql/skeleton.graphqls,"
+ "classpath:graphql-qualification/qualification.graphqls"
})
@AutoConfigureTestRestTemplate
class GraphqlHttpBoundaryQualificationTest {
private static final String USERNAME = "qualification-user";
private static final String PASSWORD = "qualification-password";
private static final String ALLOWED_ORIGIN = "https://allowed.example";
private static final String DISALLOWED_ORIGIN = "https://disallowed.example";
private static final String STABLE_CODE = "QUALIFICATION_NOT_FOUND";
private static final String CARRIER_SECRET = "carrier-secret-sqlstate-zz9";
private static final String UNKNOWN_SECRET = "unknown-secret-upstream-token-yy8";
@LocalServerPort int port;
@Autowired TestRestTemplate http;
@Test
void unauthenticatedGraphqlRequestIsRejected() {
ResponseEntity<String> response = graphql("{ _health }", false, null);
assertThat(response.getStatusCode().value()).isEqualTo(401);
}
@Test
void authenticatedHealthQuerySucceedsOverHttp() {
ResponseEntity<String> response = graphql("{ _health }", true, null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("\"_health\":\"UP\"");
}
@Test
void allowedOriginReceivesCorsPermission() {
ResponseEntity<String> response = graphql("{ _health }", true, ALLOWED_ORIGIN);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getHeaders().getAccessControlAllowOrigin()).isEqualTo(ALLOWED_ORIGIN);
}
@Test
void disallowedOriginIsRejected() {
ResponseEntity<String> response = graphql("{ _health }", true, DISALLOWED_ORIGIN);
assertThat(response.getStatusCode().value()).isEqualTo(403);
assertThat(response.getHeaders().getAccessControlAllowOrigin()).isNull();
}
@Test
void graphiqlIsDisabledAtTheHttpBoundary() {
ResponseEntity<String> response =
http.withBasicAuth(USERNAME, PASSWORD).getForEntity(endpoint("/graphiql"), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
}
@Test
void schemaIntrospectionIsDisabledAtTheHttpBoundary() {
ResponseEntity<String> response = graphql("{ __schema { queryType { name } } }", true, null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("\"errors\"").doesNotContain("\"queryType\"");
}
@Test
void carrierErrorExposesStableCodeAndCategoryWithoutRawMessage() {
ResponseEntity<String> response = graphql("{ carrierFailure }", true, null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("\"message\":\"" + STABLE_CODE + "\"")
.contains("\"code\":\"" + STABLE_CODE + "\"")
.contains("\"category\":\"NOT_FOUND\"")
.doesNotContain(CARRIER_SECRET, UNKNOWN_SECRET);
}
@Test
void unknownErrorUsesFrameworkFallbackWithoutRawMessage() {
ResponseEntity<String> response = graphql("{ unknownFailure }", true, null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("\"classification\":\"INTERNAL_ERROR\"")
.doesNotContain(CARRIER_SECRET, UNKNOWN_SECRET);
}
private ResponseEntity<String> graphql(String query, boolean authenticated, String origin) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (authenticated) {
headers.setBasicAuth(USERNAME, PASSWORD);
}
if (origin != null) {
headers.setOrigin(origin);
}
RequestEntity<String> request =
new RequestEntity<>(
"{\"query\":\"" + query + "\"}", headers, HttpMethod.POST, endpoint("/graphql"));
return http.exchange(request, String.class);
}
private URI endpoint(String path) {
return URI.create("http://localhost:" + port + path);
}
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({
HealthGraphqlController.class,
GraphqlExceptionResolver.class,
QualificationController.class,
TestSecurityConfiguration.class
})
static class TestApplication {}
@Controller
static class QualificationController {
@QueryMapping
String carrierFailure() {
throw new QualificationCarrierException(CARRIER_SECRET);
}
@QueryMapping
String unknownFailure() {
throw new IllegalStateException(UNKNOWN_SECRET);
}
}
@Configuration(proxyBeanMethods = false)
static class TestSecurityConfiguration {
/**
* Boot's schema condition does not inspect additional-files. This no-op customizer activates
* auto-configuration while the exact shipped schema and test extension are supplied above.
*/
@Bean
GraphQlSourceBuilderCustomizer qualificationSchemaActivation() {
return builder -> {};
}
@Bean
SecurityFilterChain qualificationSecurityFilterChain(
HttpSecurity http,
@Qualifier("qualificationCorsConfigurationSource")
CorsConfigurationSource corsConfigurationSource)
throws Exception {
return http.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.build();
}
@Bean
UserDetailsService qualificationUsers() {
return new InMemoryUserDetailsManager(
User.withUsername(USERNAME).password("{noop}" + PASSWORD).roles("QUALIFICATION").build());
}
@Bean
CorsConfigurationSource qualificationCorsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(ALLOWED_ORIGIN));
configuration.setAllowedMethods(List.of("POST"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/graphql", configuration);
return source;
}
}
private static final class QualificationCarrierException extends RuntimeException
implements ApiErrorCarrier {
private static final long serialVersionUID = 1L;
QualificationCarrierException(String message) {
super(message);
}
@Override
public ApiErrorCode errorCode() {
return QualificationErrorCode.INSTANCE;
}
}
private enum QualificationErrorCode implements ApiErrorCode {
INSTANCE;
@Override
public String code() {
return STABLE_CODE;
}
@Override
public Category category() {
return Category.NOT_FOUND;
}
@Override
public int httpStatus() {
return 404;
}
@Override
public boolean retryable() {
return false;
}
}
}
@@ -0,0 +1,4 @@
extend type Query {
carrierFailure: String
unknownFailure: String
}
+32 -15
View File
@@ -16,23 +16,25 @@ Package root: `dev.caskeleton.adapter.inbound.grpc`.
## Responsibility
- gRPC 전송 인프라만: 서버 수명주기(`GrpcServerRunner`), 타입드 설정(`GrpcServerProperties`),
프로토콜 에러 매핑(`GrpcStatusMapper` + `GrpcExceptionHandlingInterceptor`), 그리고 `.proto`
없이도 부팅하는 최소 표면(standard health + reflection).
- feature-agnostic: 모든 `io.grpc.BindableService` 빈을 generic 하게 등록한다. **WorkLog 등
구체 기능을 이름으로 알지 않는다.**
feature 인증 정책 경계, 프로토콜 에러 매핑(`GrpcStatusMapper` +
`GrpcExceptionHandlingInterceptor`), 그리고 `.proto` 없이도 부팅하는 최소 표면(standard
health, 명시적으로 켠 경우에만 reflection).
- feature-agnostic: 모든 `io.grpc.BindableService` 빈을 generic 하게 등록하며 구체 기능을
이름으로 알지 않는다.
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`.
- `io.grpc:*` (grpc-netty-shaded / grpc-protobuf / grpc-stub / grpc-services), `spring-boot-starter`.
- `io.grpc:*` (grpc-netty-shaded / grpc-protobuf / grpc-stub / grpc-services),
`spring-boot-starter`, `spring-boot-starter-validation`.
## Forbidden
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`).
- 이 스켈레톤 모듈에서의 `com.google.protobuf` 플러그인 / `.proto` — 스키마와 서비스는 feature
(sample) 모듈이 소유한다.
- 이 스켈레톤 모듈에서의 `com.google.protobuf` 플러그인 / `.proto`향후 도입하는 스키마와
서비스는 consuming feature 모듈이 소유한다.
- 프로덕션 feature RPC 를 스켈레톤에 두는 것 — health/reflection 표면만 (web 의
`HealthcheckController` 와 동일 원칙).
@@ -43,18 +45,33 @@ Package root: `dev.caskeleton.adapter.inbound.grpc`.
| key | default | 의미 |
|---|---|---|
| `enabled` | `true` | gRPC 서버 기동 여부. 프로덕션 composition root 는 property 로 끌 수 있다 |
| `enabled` | `false` | `true`를 명시해야만 관련 빈과 listener가 생긴다 |
| `port` | `9090` | 바인딩 TCP 포트. `0` 이면 ephemeral 포트(테스트) |
| `reflectionEnabled` | `true` | v1 server reflection 노출(grpcurl/Postman 편의; 프로덕션에선 끄기) |
| `shutdownGraceSeconds` | `5` | graceful shutdown 시 in-flight RPC 대기 초 |
| `bindAddress` | `127.0.0.1` | P1 insecure listener 바인드. loopback 주소만 허용한다 |
| `allowInsecureLocal` | `false` | local plaintext 위험을 명시적으로 승인하는 개발용 override |
| `reflectionEnabled` | `false` | v1 server reflection 노출을 독립적으로 opt-in 한다 |
| `shutdownGraceSeconds` | `5` | graceful shutdown 시 in-flight RPC 대기 초(0 이상) |
`port`는 0..65535 범위여야 한다. 현재 transport credential은 plaintext뿐이므로
`enabled=true``allowInsecureLocal=true`와 실제 loopback `bindAddress`가 함께 없으면
configuration binding/startup 단계에서 실패한다.
## Feature 기여 방법
feature 모듈은 `io.grpc.BindableService``@Bean` 으로 등록하기만 하면
`GrpcServerRunner``ObjectProvider` 가 자동으로 인터셉터 뒤에 등록한다. 에러는
`ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)로 던지
`GrpcExceptionHandlingInterceptor` 가 매핑한다. `sample-portfolio` 를 지워도 스켈레톤은
health + reflection 만으로 부팅한다 (disposability).
현재 저장소에는 production feature RPC나 sample gRPC service가 없다. 향후 feature를 도입할
때는 `.proto`/generated stub/`BindableService`를 해당 feature가 소유하고, 서비스 빈과 정확히 한
개의 caller-supplied `GrpcAuthenticationPolicy` 빈을 함께 제공한다. 정책이 없거나 여러 개
listener 시작이 실패한다. 정책이 `false`를 반환하거나 예외를 던진 요청은 feature handler에 닿지
않고 안정적인 `UNAUTHENTICATED` status/code/category로 종료된다.
## P1 증거와 한계
- `GrpcSafeActivationTest`: 기본 비활성, 관련 빈 부재, 설정 검증, feature 인증 정책 필수 조건.
- `GrpcP1BoundaryWireTest`: 실제 loopback ephemeral Netty unary service의 auth 성공/실패,
reflection-off, handler/listener/observer/raw-status 오류 sanitization과 sentinel redaction.
이 증거는 local insecure unary qualification일 뿐 production-ready 근거가 아니다. TLS/mTLS,
external bind, deadline, streaming/backpressure, generated protobuf 호환성은 P2로 남아 있다.
## Test
+28 -19
View File
@@ -21,35 +21,33 @@
- **graceful shutdown.** `shutdownGraceSeconds` 동안 in-flight RPC 를 기다린 뒤
`shutdownNow()`. 종료 진입 시 health 를 `enterTerminalState()`(NOT_SERVING)로 뒤집어
로드밸런서가 드레이닝을 인지하게 한다.
- **insecure bind (기본).** 스켈레톤은 참조 포스처와 동일하게 평문으로 바인딩하고 mTLS 는
범위 밖(문서화된 knob). 프로덕션 fork 가 전송 보안을 얹는다.
- **fail-closed local insecure bind.** 기본은 서버 비활성·reflection 비활성이다. 현재 구현의
plaintext credential은 `allowInsecureLocal=true`를 명시하고 실제 loopback 주소에 바인딩할
때만 허용한다. wildcard/외부 주소의 insecure 시작은 실패한다.
## 왜 `.proto` 도 protobuf 플러그인도 없는가
이 모듈은 protobuf 를 **하나도 컴파일하지 않는다**`com.google.protobuf` 플러그인도,
`src/main/proto` 도 없다. health(`grpc.health.v1`) 와 v1 server reflection 은 `grpc-services`
런타임 jar 에 이미 컴파일된 채 들어 있어, 스켈레톤은 **RPC 0개**로도 동작하는 health +
reflection 표면을 갖고 부팅한다. 기능(feature)의 `.proto`/서비스/매퍼는 `sample-portfolio`
gRPC 어댑터가 `com.google.protobuf` 플러그인과 함께 소유한다.
`compileOnly org.apache.tomcat:annotations-api` 는 생성된 stub 이 참조하는
`javax.annotation.Generated` 때문 — 스켈레톤 자체는 stub 을 생성하지 않지만 feature 모듈과의
패리티를 위해 선언한다.
런타임 jar에 이미 컴파일된 채 들어 있다. 서버를 명시적으로 켜면 feature RPC가 없어도 health
수명주기를 확인할 수 있고, reflection은 별도 flag를 켠 경우에만 등록된다. 현재 저장소에는 feature
`.proto`, generated stub, feature gRPC service가 없다. 향후 도입하는 feature 모듈이 이들을
소유해야 한다.
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** `GrpcServerRunner` 는 생성자에서
`ObjectProvider<io.grpc.BindableService>` 를 받아, 컨텍스트에 존재하는 **모든**
`BindableService` 빈을 `ServerInterceptors.intercept(service, exceptionInterceptor)` 로 감싸
등록한다. 그래서 sample 의 `WorkLogGrpcService` 같은 feature 서비스가 스켈레톤을 수정하지 않고
자동 등록된다. `sample-portfolio` 를 지우면 스켈레톤은 여전히 health + reflection 만으로 부팅한다
(web 과 동일한 disposability 보장).
향후 feature 모듈은 `BindableService`와 정확히 한 개의 `GrpcAuthenticationPolicy`를 Spring
빈으로 함께 기여한다. runner는 feature 이름을 알지 않고 generic하게 등록하되, 서비스가 하나라도
있는데 인증 정책이 없거나 단일하지 않으면 listener 시작을 거부한다. 정책은 gRPC `Metadata`만 받아
Spring Security에 결합되지 않으며, `false` 반환과 policy 예외는 동일한 안정적
`UNAUTHENTICATED` 계약으로 끝난다.
## 에러 매핑 — web `GlobalExceptionHandler` 의 gRPC 형제
`GrpcExceptionHandlingInterceptor` 가 핸들러에서 동기적으로 던져진 `RuntimeException` 을 잡아
`ServerCall.close(status, trailers)` 로 변환한다. 서비스 구현은 web 컨트롤러처럼 "그냥 던지기만"
하고, 이 인터셉터가 와이어 계약을 단일 소유한다.
`GrpcExceptionHandlingInterceptor`는 forwarding `ServerCall``close`까지 감싼다. 동기
handler throw, listener callback throw, `responseObserver.onError(...)`, raw
`StatusRuntimeException`이 모두 같은 sanitizer를 거친다. 서비스 구현은 web 컨트롤러처럼 "그냥
던지기만" 하고, 이 인터셉터가 와이어 계약을 단일 소유한다.
- **와이어 status(coarse)** 는 `GrpcStatusMapper.toStatus(Category)` 가 결정한다(HTTP status 가
coarse 인 것과 동형). 정확한 `code`/`category``Status` trailer `Metadata`(`error-code` /
@@ -59,7 +57,8 @@ gRPC 어댑터가 `com.google.protobuf` 플러그인과 함께 소유한다.
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)도 직접 인식한다.
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 status description/trailer 로 노출하고, raw
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. 인식되지
않은 `RuntimeException``Status.INTERNAL` + `INTERNAL_ERROR` 로 폴백한다.
않은 예외와 raw gRPC status/description은 원래 status를 신뢰하지 않고 `Status.INTERNAL` +
`INTERNAL_ERROR`로 폴백한다. 입력 trailers도 폐기한다.
`Category → Status` 표(설계 스펙 Error Mapping SSOT):
@@ -83,3 +82,13 @@ Spring Boot BOM 은 `io.grpc:*`/protobuf 버전을 관리하지 않고 이 저
`dependencyManagement` 에서 platform 으로 import 한다(루트 `ext.grpcVersion`/`ext.protobufVersion`
가 단일 SSOT). 모듈 스코프로 두어 strict per-module lockfile 의 blast radius 를 이 모듈에만
가둔다 — 공유 루트 dependencyManagement 블록은 io.grpc-free 로 유지된다.
## P1 qualification과 P2 유보
`GrpcSafeActivationTest`는 disabled bean/listener 부재와 설정/인증-policy fail-closed를 검증한다.
`GrpcP1BoundaryWireTest`는 실제 loopback ephemeral Netty unary service로 auth 성공/실패,
reflection-off, 모든 오류 경로의 안정 code/category 및 sentinel redaction을 검증한다.
이는 local plaintext unary 경계에 대한 P1 증거이며 production-ready 주장 근거가 아니다.
TLS/mTLS, external bind, deadline, streaming/backpressure, generated protobuf 호환성은 P2에서 별도
설계·검증해야 한다.
+21 -4
View File
@@ -3,12 +3,14 @@
// A SmartLifecycle bean (GrpcServerRunner) owns the io.grpc Netty server, so this module depends on
// NO third-party grpc-spring-boot starter (no Spring Boot version coupling). The skeleton compiles
// NO protobuf: there is no `com.google.protobuf` plugin and no `.proto` here — health + reflection
// come from grpc-services at runtime, and feature `.proto`/services live in the sample module.
// come from grpc-services at runtime, and a future consuming feature owns its `.proto`/services.
//
// io.grpc:* / protobuf versions are NOT managed by the Spring Boot BOM, and this repo has no version
// catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root
// `ext.grpcVersion` / `ext.protobufVersion` SSOT — this keeps the strict-locking blast radius to
// this module (the shared root dependencyManagement block stays io.grpc-free).
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
dependencyManagement {
imports {
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
@@ -20,13 +22,28 @@ dependencies {
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'io.grpc:grpc-netty-shaded'
implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection)
// Keep the direct versions in the outgoing project metadata as well as importing the BOM.
// Spring dependency-management constraints are local to this leaf and are not propagated to
// a consumer's custom qualification source set.
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
implementation "io.grpc:grpc-services:${grpcVersion}" // health + reflection
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// The boot test directly builds generated health/reflection protobuf messages. grpc-services
// does not expose protobuf-java on its compile API, so keep the narrower test-only declaration.
testImplementation 'io.grpc:grpc-protobuf'
testImplementation "io.grpc:grpc-protobuf:${grpcVersion}"
// Wire qualification directly uses ClientCalls/ServerCalls/MetadataUtils without generated stubs.
testImplementation "io.grpc:grpc-stub:${grpcVersion}"
}
registerStrictQualificationTest(
name: 'grpcTransportQualificationTest',
sourceSet: sourceSets.test,
requiredClasses: [
'dev.caskeleton.adapter.inbound.grpc.GrpcSafeActivationTest',
'dev.caskeleton.adapter.inbound.grpc.GrpcP1BoundaryWireTest'
],
description: 'Runs exact no-skip gRPC conditional transport wire evidence.')
+8 -2
View File
@@ -5,6 +5,7 @@ biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspa
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
@@ -63,6 +64,7 @@ io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,te
io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
@@ -86,7 +88,7 @@ org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
@@ -100,7 +102,9 @@ org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
@@ -111,7 +115,7 @@ org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
@@ -145,12 +149,14 @@ org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runt
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.inbound.grpc;
import dev.caskeleton.shared.error.OperationalError;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
/** Applies the caller-supplied authentication policy before a feature RPC can reach its handler. */
final class GrpcAuthenticationInterceptor implements ServerInterceptor {
private final GrpcAuthenticationPolicy authenticationPolicy;
private final GrpcStatusMapper statusMapper;
GrpcAuthenticationInterceptor(
GrpcAuthenticationPolicy authenticationPolicy, GrpcStatusMapper statusMapper) {
this.authenticationPolicy = authenticationPolicy;
this.statusMapper = statusMapper;
}
@Override
public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall(
ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) {
if (isAuthenticated(headers)) {
return next.startCall(call, headers);
}
call.close(
Status.UNAUTHENTICATED.withDescription(OperationalError.UNAUTHENTICATED.code()),
statusMapper.trailersFor(OperationalError.UNAUTHENTICATED));
return new ServerCall.Listener<>() {};
}
private boolean isAuthenticated(Metadata headers) {
try {
return authenticationPolicy.isAuthenticated(headers);
} catch (RuntimeException ignored) {
return false;
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.inbound.grpc;
import io.grpc.Metadata;
/**
* Caller-supplied policy that authenticates feature RPC metadata without Spring Security coupling.
*/
@FunctionalInterface
public interface GrpcAuthenticationPolicy {
/**
* Returns {@code true} only when the request metadata represents an authenticated caller. A
* {@code false} result or policy exception becomes the same stable {@code UNAUTHENTICATED} wire
* contract; policy diagnostics never reach the client.
*/
boolean isAuthenticated(Metadata metadata);
}
@@ -3,6 +3,7 @@ package dev.caskeleton.adapter.inbound.grpc;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.OperationalError;
import io.grpc.ForwardingServerCall.SimpleForwardingServerCall;
import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener;
import io.grpc.Metadata;
import io.grpc.ServerCall;
@@ -12,19 +13,20 @@ import io.grpc.Status;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Centralises the gRPC error contract: a feature service just throws, and this {@link
* ServerInterceptor} translates a synchronous {@link RuntimeException} from the handler into a
* {@code ServerCall#close(Status, Metadata)} carrying the mapped {@link Status} plus {@code code} /
* {@code category} trailers — the gRPC sibling of the web adapter's {@code GlobalExceptionHandler}.
* Centralises the gRPC error contract: a feature service just throws or calls {@code onError}, and
* this {@link ServerInterceptor} wraps handler/listener throws and every non-OK {@link
* ServerCall#close(Status, Metadata)} behind one sanitizer. The result carries a mapped {@link
* Status} plus stable {@code code} / {@code category} trailers — the gRPC sibling of the web
* adapter's {@code GlobalExceptionHandler}.
*
* <p>A stable {@link ApiErrorCode} is recognised through the shared-contract {@link
* ApiErrorCarrier} hook — implemented by a feature throwable (the gRPC {@link ApiErrorException}
* carrying a mapped domain code) and by the shared-contract {@code PersistenceFailureException} /
* {@code DependencyFailureException}, so a single {@code instanceof ApiErrorCarrier} branch covers
* them all. An unrecognised {@link RuntimeException} maps to {@link Status#INTERNAL} with {@link
* OperationalError#INTERNAL_ERROR}. Only the stable code string reaches the client (via the status
* description and trailers) — a raw exception message, which may carry a SQLState or upstream
* detail, is never surfaced.
* them all. An unrecognised exception or raw gRPC status maps to {@link Status#INTERNAL} with
* {@link OperationalError#INTERNAL_ERROR}. Only the stable code string reaches the client (via the
* status description and trailers) — raw descriptions and input trailers, which may carry a
* SQLState or upstream detail, are never surfaced.
*/
public class GrpcExceptionHandlingInterceptor implements ServerInterceptor {
@@ -38,11 +40,12 @@ public class GrpcExceptionHandlingInterceptor implements ServerInterceptor {
public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall(
ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) {
AtomicBoolean closed = new AtomicBoolean(false);
ServerCall<REQT, RESPT> sanitizingCall = sanitizingCall(call, closed);
ServerCall.Listener<REQT> delegate;
try {
delegate = next.startCall(call, headers);
delegate = next.startCall(sanitizingCall, headers);
} catch (RuntimeException e) {
closeWithError(call, closed, e);
closeWithError(sanitizingCall, e);
return new ServerCall.Listener<>() {};
}
return new SimpleForwardingServerCallListener<>(delegate) {
@@ -61,35 +64,67 @@ public class GrpcExceptionHandlingInterceptor implements ServerInterceptor {
runGuarded(super::onReady);
}
@Override
public void onCancel() {
runGuarded(super::onCancel);
}
@Override
public void onComplete() {
runGuarded(super::onComplete);
}
private void runGuarded(Runnable action) {
if (closed.get()) {
return;
}
try {
action.run();
} catch (RuntimeException e) {
closeWithError(call, closed, e);
closeWithError(sanitizingCall, e);
}
}
};
}
private void closeWithError(
ServerCall<?, ?> call, AtomicBoolean closed, RuntimeException exception) {
if (!closed.compareAndSet(false, true)) {
return; // the call was already closed once — never double-close.
}
ApiErrorCode code = errorCodeOf(exception);
Status status;
if (code != null) {
status = statusMapper.toStatus(code.category()).withDescription(code.code());
} else {
code = OperationalError.INTERNAL_ERROR;
status = Status.INTERNAL.withDescription(code.code());
}
call.close(status, statusMapper.trailersFor(code));
private <REQT, RESPT> ServerCall<REQT, RESPT> sanitizingCall(
ServerCall<REQT, RESPT> delegate, AtomicBoolean closed) {
return new SimpleForwardingServerCall<>(delegate) {
@Override
public void close(Status status, Metadata trailers) {
if (!closed.compareAndSet(false, true)) {
return;
}
if (status.isOk()) {
super.close(status, trailers);
return;
}
ApiErrorCode code = errorCodeOf(status.getCause());
if (code == null) {
code = OperationalError.INTERNAL_ERROR;
}
super.close(
statusMapper.toStatus(code.category()).withDescription(code.code()),
statusMapper.trailersFor(code));
}
};
}
private static void closeWithError(ServerCall<?, ?> call, RuntimeException exception) {
call.close(Status.fromThrowable(exception).withCause(exception), new Metadata());
}
private static ApiErrorCode errorCodeOf(Throwable throwable) {
if (throwable instanceof ApiErrorCarrier carrier) {
return carrier.errorCode();
Throwable current = throwable;
while (current != null) {
if (current instanceof ApiErrorCarrier carrier) {
return carrier.errorCode();
}
if (current.getCause() == current) {
break;
}
current = current.getCause();
}
return null;
}
@@ -9,7 +9,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires the gRPC transport machinery, only when {@code ca-skeleton.grpc.enabled=true} (default).
* Wires the gRPC transport machinery only when {@code ca-skeleton.grpc.enabled=true} is explicit.
* All collaborators are plain objects composed here, mirroring the clean DI style used across the
* skeleton. Feature {@link BindableService} beans are injected via {@link ObjectProvider} and
* registered generically by {@link GrpcServerRunner}. See README.
@@ -19,7 +19,7 @@ import org.springframework.context.annotation.Configuration;
prefix = "ca-skeleton.grpc",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
matchIfMissing = false)
@EnableConfigurationProperties(GrpcServerProperties.class)
public class GrpcServerConfig {
@@ -41,9 +41,17 @@ public class GrpcServerConfig {
@Bean
GrpcServerRunner grpcServerRunner(
ObjectProvider<BindableService> services,
ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies,
GrpcServerProperties properties,
GrpcExceptionHandlingInterceptor exceptionInterceptor,
GrpcStatusMapper statusMapper,
HealthStatusManager healthStatusManager) {
return new GrpcServerRunner(services, properties, exceptionInterceptor, healthStatusManager);
return new GrpcServerRunner(
services,
authenticationPolicies,
properties,
exceptionInterceptor,
statusMapper,
healthStatusManager);
}
}
@@ -1,25 +1,42 @@
package dev.caskeleton.adapter.inbound.grpc;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.net.InetAddress;
import java.net.UnknownHostException;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* gRPC server settings bound from {@code ca-skeleton.grpc.*}. Typed configuration only (no
* per-module {@code yml}); values live in the composition-root {@code application.yml}, matching
* the ca-skeleton config convention. See README for the self-managed-Netty rationale.
*/
@ConfigurationProperties(prefix = "ca-skeleton.grpc")
@ConfigurationProperties(prefix = "ca-skeleton.grpc", ignoreUnknownFields = false)
@Validated
public class GrpcServerProperties {
/** Whether to start the gRPC server at all (feature composition can keep it off by property). */
private boolean enabled = true;
/** Whether to start the gRPC server at all. Activation must always be explicit. */
private boolean enabled;
/** TCP port the gRPC server binds to. Set to {@code 0} to bind an ephemeral port (tests). */
@Min(0)
@Max(65535)
private int port = 9090;
/** Expose server reflection (handy for grpcurl / Postman; disable in production). */
private boolean reflectionEnabled = true;
/** Loopback address used by the P1 local-only insecure listener. */
@NotBlank private String bindAddress = "127.0.0.1";
/** Explicit acknowledgement that the enabled P1 listener is plaintext and local-only. */
private boolean allowInsecureLocal;
/** Expose server reflection only when explicitly requested for local development. */
private boolean reflectionEnabled;
/** Seconds to wait for in-flight RPCs to finish on graceful shutdown. */
@Min(0)
private int shutdownGraceSeconds = 5;
public boolean isEnabled() {
@@ -38,6 +55,22 @@ public class GrpcServerProperties {
this.port = port;
}
public String getBindAddress() {
return bindAddress;
}
public void setBindAddress(String bindAddress) {
this.bindAddress = bindAddress;
}
public boolean isAllowInsecureLocal() {
return allowInsecureLocal;
}
public void setAllowInsecureLocal(boolean allowInsecureLocal) {
this.allowInsecureLocal = allowInsecureLocal;
}
public boolean isReflectionEnabled() {
return reflectionEnabled;
}
@@ -53,4 +86,29 @@ public class GrpcServerProperties {
public void setShutdownGraceSeconds(int shutdownGraceSeconds) {
this.shutdownGraceSeconds = shutdownGraceSeconds;
}
@AssertTrue(
message = "insecure gRPC requires allow-insecure-local=true and a loopback bind address")
public boolean isInsecureLocalConfigurationValid() {
return !enabled || (allowInsecureLocal && isLoopbackBindAddress());
}
InetAddress resolvedBindAddress() {
try {
return InetAddress.getByName(bindAddress);
} catch (UnknownHostException e) {
throw new IllegalStateException("gRPC bind address cannot be resolved", e);
}
}
private boolean isLoopbackBindAddress() {
if (bindAddress == null || bindAddress.isBlank()) {
return false;
}
try {
return resolvedBindAddress().isLoopbackAddress();
} catch (IllegalStateException ignored) {
return false;
}
}
}
@@ -1,15 +1,17 @@
package dev.caskeleton.adapter.inbound.grpc;
import io.grpc.BindableService;
import io.grpc.Grpc;
import io.grpc.InsecureServerCredentials;
import io.grpc.Server;
import io.grpc.ServerInterceptors;
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.protobuf.services.HealthStatusManager;
import io.grpc.protobuf.services.ProtoReflectionServiceV1;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,29 +24,34 @@ import org.springframework.context.SmartLifecycle;
* grpc-spring-boot starter so the skeleton has no Spring Boot version coupling.
*
* <p>Feature services are discovered generically: every {@link BindableService} bean is registered
* behind the {@link GrpcExceptionHandlingInterceptor}, so a feature (e.g. the sample WorkLog
* service) auto-registers without the skeleton naming it. The skeleton also registers the standard
* {@code grpc.health.v1} health service and, when enabled, the v1 server reflection service, so it
* boots with a working surface and ZERO {@code .proto}. See README.
* behind caller-supplied authentication and the {@link GrpcExceptionHandlingInterceptor}. The
* skeleton also registers standard {@code grpc.health.v1} health and, only when explicitly enabled,
* v1 server reflection, so it needs no feature {@code .proto}. See README.
*/
public class GrpcServerRunner implements SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(GrpcServerRunner.class);
private final ObjectProvider<BindableService> services;
private final ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies;
private final GrpcServerProperties properties;
private final GrpcExceptionHandlingInterceptor exceptionInterceptor;
private final GrpcStatusMapper statusMapper;
private final HealthStatusManager healthStatusManager;
private volatile Server server;
public GrpcServerRunner(
ObjectProvider<BindableService> services,
ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies,
GrpcServerProperties properties,
GrpcExceptionHandlingInterceptor exceptionInterceptor,
GrpcStatusMapper statusMapper,
HealthStatusManager healthStatusManager) {
this.services = services;
this.authenticationPolicies = authenticationPolicies;
this.properties = properties;
this.exceptionInterceptor = exceptionInterceptor;
this.statusMapper = statusMapper;
this.healthStatusManager = healthStatusManager;
}
@@ -53,12 +60,26 @@ public class GrpcServerRunner implements SmartLifecycle {
if (isRunning()) {
return;
}
var builder =
Grpc.newServerBuilderForPort(properties.getPort(), InsecureServerCredentials.create());
List<BindableService> featureServices = services.orderedStream().toList();
List<GrpcAuthenticationPolicy> policies = authenticationPolicies.orderedStream().toList();
if (!featureServices.isEmpty() && policies.size() != 1) {
throw new IllegalStateException(
"feature gRPC services require exactly one caller-supplied authentication policy");
}
GrpcAuthenticationPolicy authenticationPolicy =
policies.size() == 1 ? policies.getFirst() : null;
var address = new InetSocketAddress(properties.resolvedBindAddress(), properties.getPort());
var builder = NettyServerBuilder.forAddress(address, InsecureServerCredentials.create());
var authenticationInterceptor =
authenticationPolicy == null
? null
: new GrpcAuthenticationInterceptor(authenticationPolicy, statusMapper);
int registered = 0;
for (BindableService service : services) {
builder.addService(ServerInterceptors.intercept(service, exceptionInterceptor));
for (BindableService service : featureServices) {
builder.addService(
ServerInterceptors.intercept(service, exceptionInterceptor, authenticationInterceptor));
registered++;
}
@@ -85,6 +85,7 @@ class GrpcExceptionHandlingInterceptorTest {
* Feature-style exception carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
*/
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
private static final long serialVersionUID = 1L;
private final ApiErrorCode errorCode;
CarrierException(ApiErrorCode errorCode) {
@@ -0,0 +1,355 @@
package dev.caskeleton.adapter.inbound.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import dev.caskeleton.shared.error.OperationalError;
import io.grpc.BindableService;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientInterceptors;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerServiceDefinition;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.reflection.v1.ServerReflectionGrpc;
import io.grpc.reflection.v1.ServerReflectionRequest;
import io.grpc.reflection.v1.ServerReflectionResponse;
import io.grpc.stub.ClientCalls;
import io.grpc.stub.MetadataUtils;
import io.grpc.stub.ServerCalls;
import io.grpc.stub.StreamObserver;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class GrpcP1BoundaryWireTest {
private static final String SERVICE_NAME = "test.p1.Feature";
private static final String AUTH_TOKEN = "Bearer p1-test-token";
private static final String INVALID_TOKEN_SENTINEL = "invalid-token-secret-sentinel";
private static final String HANDLER_SENTINEL = "handler-secret-sentinel";
private static final String LISTENER_SENTINEL = "listener-secret-sentinel";
private static final String CARRIER_SENTINEL = "carrier-secret-sentinel";
private static final String RAW_STATUS_SENTINEL = "raw-status-secret-sentinel";
private static final Metadata.Key<String> AUTHORIZATION =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private static final MethodDescriptor<String, String> UNARY_METHOD = unaryMethod("UnaryFeature");
private static final MethodDescriptor<String, String> HANDLER_THROW_METHOD =
unaryMethod("HandlerThrow");
private static final MethodDescriptor<String, String> LISTENER_THROW_METHOD =
unaryMethod("ListenerThrow");
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner()
.withUserConfiguration(GrpcServerConfig.class, FeatureConfiguration.class)
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true",
"ca-skeleton.grpc.reflection-enabled=false");
@Test
void missingAuthenticationMetadataIsRejectedWithAStableContract() {
withChannel(
channel ->
assertFailure(
channel,
UNARY_METHOD,
"ok",
Status.Code.UNAUTHENTICATED,
"UNAUTHENTICATED",
"AUTH",
null));
}
@Test
void invalidAuthenticationMetadataIsRejectedWithoutEchoingIt() {
withChannel(
channel -> {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, "Bearer " + INVALID_TOKEN_SENTINEL);
assertFailure(
attach(channel, headers),
UNARY_METHOD,
"ok",
Status.Code.UNAUTHENTICATED,
"UNAUTHENTICATED",
"AUTH",
INVALID_TOKEN_SENTINEL);
});
}
@Test
void validAuthenticationMetadataReachesTheFeatureService() {
withChannel(
channel ->
assertThat(unary(authenticated(channel), UNARY_METHOD, "ok"))
.isEqualTo("authorized-ok"));
}
@Test
void reflectionRemainsUnavailableWhenItsIndependentFlagIsFalse() {
withChannel(
channel -> {
Throwable failure = reflectionFailure(channel);
assertThat(Status.fromThrowable(failure).getCode()).isEqualTo(Status.Code.UNIMPLEMENTED);
});
}
@Test
void synchronousHandlerThrowUsesTheStableCarrierContract() {
withChannel(
channel ->
assertFailure(
authenticated(channel),
HANDLER_THROW_METHOD,
"ignored",
Status.Code.INVALID_ARGUMENT,
"BAD_PARAMETER",
"VALIDATION",
HANDLER_SENTINEL));
}
@Test
void listenerThrowUsesTheStableCarrierContract() {
withChannel(
channel ->
assertFailure(
authenticated(channel),
LISTENER_THROW_METHOD,
"ignored",
Status.Code.NOT_FOUND,
"ROUTE_NOT_FOUND",
"NOT_FOUND",
LISTENER_SENTINEL));
}
@Test
void responseObserverCarrierErrorUsesTheStableCarrierContract() {
withChannel(
channel ->
assertFailure(
authenticated(channel),
UNARY_METHOD,
"carrier-error",
Status.Code.RESOURCE_EXHAUSTED,
"RATE_LIMIT_EXCEEDED",
"RATE_LIMIT",
CARRIER_SENTINEL));
}
@Test
void rawStatusRuntimeExceptionIsSanitizedToInternal() {
withChannel(
channel ->
assertFailure(
authenticated(channel),
UNARY_METHOD,
"raw-status-error",
Status.Code.INTERNAL,
"INTERNAL_ERROR",
"INTERNAL",
RAW_STATUS_SENTINEL));
}
private void withChannel(Consumer<ManagedChannel> assertion) {
contextRunner.run(
context -> {
assertThat(context.getStartupFailure()).isNull();
int port = context.getBean(GrpcServerRunner.class).getListeningPort();
ManagedChannel channel =
ManagedChannelBuilder.forAddress("127.0.0.1", port).usePlaintext().build();
try {
assertion.accept(channel);
} finally {
channel.shutdownNow();
awaitChannelTermination(channel);
}
});
}
private static void awaitChannelTermination(ManagedChannel channel) {
try {
assertThat(channel.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while closing test channel", e);
}
}
private static Channel authenticated(Channel channel) {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, AUTH_TOKEN);
return attach(channel, headers);
}
private static Channel attach(Channel channel, Metadata headers) {
return ClientInterceptors.intercept(
channel, MetadataUtils.newAttachHeadersInterceptor(headers));
}
private static String unary(
Channel channel, MethodDescriptor<String, String> method, String request) {
return ClientCalls.blockingUnaryCall(
channel, method, CallOptions.DEFAULT.withDeadlineAfter(5, TimeUnit.SECONDS), request);
}
private static void assertFailure(
Channel channel,
MethodDescriptor<String, String> method,
String request,
Status.Code expectedStatus,
String expectedCode,
String expectedCategory,
String forbiddenSentinel) {
StatusRuntimeException failure =
catchThrowableOfType(StatusRuntimeException.class, () -> unary(channel, method, request));
assertThat(failure).isNotNull();
assertThat(failure.getStatus().getCode()).isEqualTo(expectedStatus);
assertThat(failure.getStatus().getDescription()).isEqualTo(expectedCode);
assertThat(failure.getTrailers()).isNotNull();
assertThat(failure.getTrailers().get(GrpcStatusMapper.CODE_KEY)).isEqualTo(expectedCode);
assertThat(failure.getTrailers().get(GrpcStatusMapper.CATEGORY_KEY))
.isEqualTo(expectedCategory);
if (forbiddenSentinel != null) {
assertThat(failure.toString()).doesNotContain(forbiddenSentinel);
assertThat(failure.getTrailers().toString()).doesNotContain(forbiddenSentinel);
}
}
private static Throwable reflectionFailure(ManagedChannel channel) {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();
StreamObserver<ServerReflectionRequest> requests =
ServerReflectionGrpc.newStub(channel)
.serverReflectionInfo(
new StreamObserver<>() {
@Override
public void onNext(ServerReflectionResponse value) {}
@Override
public void onError(Throwable throwable) {
failure.set(throwable);
done.countDown();
}
@Override
public void onCompleted() {
done.countDown();
}
});
requests.onNext(ServerReflectionRequest.newBuilder().setListServices("").build());
requests.onCompleted();
try {
assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while waiting for reflection response", e);
}
return failure.get();
}
private static MethodDescriptor<String, String> unaryMethod(String methodName) {
return MethodDescriptor.<String, String>newBuilder()
.setType(MethodDescriptor.MethodType.UNARY)
.setFullMethodName(MethodDescriptor.generateFullMethodName(SERVICE_NAME, methodName))
.setRequestMarshaller(StringMarshaller.INSTANCE)
.setResponseMarshaller(StringMarshaller.INSTANCE)
.build();
}
@Configuration(proxyBeanMethods = false)
static class FeatureConfiguration {
@Bean
GrpcAuthenticationPolicy grpcAuthenticationPolicy() {
return metadata -> AUTH_TOKEN.equals(metadata.get(AUTHORIZATION));
}
@Bean
BindableService p1FeatureService() {
return () ->
ServerServiceDefinition.builder(SERVICE_NAME)
.addMethod(
UNARY_METHOD,
ServerCalls.asyncUnaryCall(
(String request, StreamObserver<String> observer) -> {
if ("carrier-error".equals(request)) {
observer.onError(
new ApiErrorException(
OperationalError.RATE_LIMIT_EXCEEDED, CARRIER_SENTINEL));
return;
}
if ("raw-status-error".equals(request)) {
observer.onError(
Status.ABORTED
.withDescription(RAW_STATUS_SENTINEL)
.asRuntimeException());
return;
}
observer.onNext("authorized-" + request);
observer.onCompleted();
}))
.addMethod(
HANDLER_THROW_METHOD,
(ServerCallHandler<String, String>)
(call, headers) -> {
throw new ApiErrorException(
OperationalError.BAD_PARAMETER, HANDLER_SENTINEL);
})
.addMethod(
LISTENER_THROW_METHOD,
(ServerCallHandler<String, String>)
(call, headers) -> {
call.request(1);
return new ServerCall.Listener<>() {
@Override
public void onHalfClose() {
throw new ApiErrorException(
OperationalError.ROUTE_NOT_FOUND, LISTENER_SENTINEL);
}
};
})
.build();
}
}
private enum StringMarshaller implements MethodDescriptor.Marshaller<String> {
INSTANCE;
@Override
public InputStream stream(String value) {
return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8));
}
@Override
public String parse(InputStream stream) {
try {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("failed to decode test request", e);
}
}
}
}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.inbound.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import io.grpc.BindableService;
import io.grpc.ServerServiceDefinition;
import io.grpc.protobuf.services.HealthStatusManager;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class GrpcSafeActivationTest {
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner().withUserConfiguration(GrpcServerConfig.class);
@Test
void defaultsKeepTheTransportAndReflectionDisabled() {
GrpcServerProperties properties = new GrpcServerProperties();
assertThat(properties.isEnabled()).isFalse();
assertThat(properties.isReflectionEnabled()).isFalse();
}
@Test
void missingActivationPropertyCreatesNoGrpcRuntimeBeansOrListener() {
contextRunner
.withPropertyValues("ca-skeleton.grpc.port=0")
.run(
context -> {
assertThat(context).doesNotHaveBean(GrpcServerProperties.class);
assertThat(context).doesNotHaveBean(GrpcServerRunner.class);
assertThat(context).doesNotHaveBean(HealthStatusManager.class);
assertThat(context).doesNotHaveBean(GrpcExceptionHandlingInterceptor.class);
});
}
@Test
void enabledTransportRequiresAnExplicitLocalInsecureOverride() {
contextRunner
.withPropertyValues("ca-skeleton.grpc.enabled=true", "ca-skeleton.grpc.port=0")
.run(context -> assertRootCauseContains(context.getStartupFailure(), "insecure"));
}
@Test
void insecureTransportRejectsANonLoopbackBindAddress() {
contextRunner
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=0.0.0.0",
"ca-skeleton.grpc.allow-insecure-local=true")
.run(context -> assertRootCauseContains(context.getStartupFailure(), "loopback"));
}
@Test
void enabledTransportRejectsAPortOutsideTheTcpRange() {
contextRunner
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=65536",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true")
.run(context -> assertRootCauseContains(context.getStartupFailure(), "port"));
}
@Test
void enabledTransportRejectsANegativeShutdownGrace() {
contextRunner
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true",
"ca-skeleton.grpc.shutdown-grace-seconds=-1")
.run(
context ->
assertRootCauseContains(context.getStartupFailure(), "shutdownGraceSeconds"));
}
@Test
void featureServiceRequiresACallerSuppliedAuthenticationPolicy() {
contextRunner
.withUserConfiguration(FeatureServiceConfiguration.class)
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true")
.run(context -> assertRootCauseContains(context.getStartupFailure(), "authentication"));
}
private static void assertRootCauseContains(Throwable failure, String expected) {
assertThat(failure).isNotNull();
Throwable rootCause = failure;
while (rootCause.getCause() != null) {
rootCause = rootCause.getCause();
}
assertThat(rootCause).hasMessageContaining(expected);
}
@Configuration(proxyBeanMethods = false)
static class FeatureServiceConfiguration {
@Bean
BindableService featureService() {
return () -> ServerServiceDefinition.builder("test.Feature").build();
}
}
}
@@ -33,7 +33,12 @@ class GrpcServerRunnerBootTest {
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner()
.withUserConfiguration(GrpcServerConfig.class)
.withPropertyValues("ca-skeleton.grpc.port=0");
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true",
"ca-skeleton.grpc.reflection-enabled=true");
@Test
void skeletonServerStartsAndServesHealthAndReflectionWithNoFeatures() {
+22 -3
View File
@@ -39,6 +39,9 @@ production configuration and compare the result with the committed snapshot.
단 blank audience 면 검사 건너뜀(기존 settings 계약과 일치).
- **JWKS lazy discovery** (`SupplierJwtDecoder`): 기동 시 IdP 가 reachable 일 필요가 없고, 첫 decode
시점에 issuer-uri/`.well-known` 네트워크 호출이 일어난다(Spring Boot auto-config 와 동일한 lazy 동작).
lazy 초기화 중 외부 discovery/JWKS I/O 실패는 필터 밖 runtime exception으로 탈출시키지 않고
`AUTH_JWKS_UNAVAILABLE`로 분류하며, 비-I/O 초기화 실패는 `INTERNAL_AUTH_MISCONFIGURATION`으로
fail-closed 한다. 두 carrier 모두 고정 진단만 가지며 원격 응답/URL은 공개 응답에 넣지 않는다.
- **Minimal 결정**: JWKS cache TTL 과 unknown-kid rate-limit 은 override 하지 않는다. 정확한 수치는
IdP-side token TTL 에 달린 NEEDS_CONTEXT 라 Nimbus/Spring 기본값을 쓰고 문서로만 남긴다.
- `jwtValidator` 가 package-private + static 인 이유: 네트워크/IdP 의존 없이 단위 테스트 가능하게 하려고.
@@ -65,6 +68,13 @@ arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지
손상·초과 snapshot은 인증 없음으로 fail closed한다. 실제 security filter save/restore 테스트가 다음
요청에서 principal과 authorities가 복원되고 session에는 primitive snapshot만 남는 것을 검증한다.
응답 본문 flush/redirect/error가 새 session보다 먼저 commit되지 않도록 repository가 Spring Security의
commit-aware response wrapper 계약을 구현한다. 또한 이 모듈은 HTML 로그인 복귀용 request cache를
사용하지 않는 API 경계이므로 request cache를 명시적으로 비활성화한다. 따라서 미인증 요청이
`DefaultSavedRequest` 같은 framework object를 session에 넣지 않는다. app-bootstrap의
`redisSessionHttpIntegrationTest`가 TLS/ACL Redis와 서로 다른 세 개의 web context를 사용해 생성,
복구, logout tombstone, stale save 거부, 장애 시 controller 이전 fail-closed를 검증한다.
### SecurityErrorClassifier
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
선언한 세분화 코드를 방출한다.
@@ -158,6 +168,15 @@ arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지
- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다.
- **운영/전송/보안 예외만** 처리한다. 도메인 예외는 소비 모듈의 별도 `@RestControllerAdvice` 가 처리하고
Spring 이 두 advice 를 합성(compose)한다(CLAUDE.md 의 `@Order(HIGHEST_PRECEDENCE)` 규칙 참조).
- **클라이언트 메시지는 allowlist다.** 예외 메시지, validation interpolated message, rejected
request value, raw request URL은
`error.message`/`details`에 넣지 않는다. `ClientSafeErrorMessages`의 코드별 고정 문구와
정규화된 server-owned field, allowlisted reason code/fixed message, expectedType,
supported-method 같은 bounded 구조 메타데이터만 공개한다. collection/map index와 key는 field
path에서 제거한다.
- 코드별 문구가 명시되지 않은 operational code는 category 기반 고정 문구로 fail-closed 한다. 이
fallback은 새 코드를 실수로 진단 문자열에 연결하는 대신 transient/conflict/data-integrity 또는
`Internal server error`만 공개한다.
- `adapter-web` 에 위치하는 이유: 실행 앱이 어떤 sample 모듈에도 의존하지 않고 envelope 형식 에러 응답을
제공하도록.
- **`spanErrorRecorder`.** 프로덕션 코드를 특정 트레이서
@@ -173,7 +192,7 @@ arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지
| `MappingException` | `MAPPING_FAILED` | 400 | B3: 매퍼 내부 실패는 `MAPPING_FAILED` 로, `BAD_PARAMETER`/`INTERNAL_ERROR` 로 보내지 않음 |
| `AdapterDisabledException` | `ADAPTER_DISABLED` | 500 (retryable=false) | Layer 3 런타임 fail-fast(integration-adapter-templates §4/D4). 시작-수명주기용 `REQUIRED_ADAPTER_DISABLED` 가 아님(§Audit A2). 예외 메시지의 어댑터 이름은 서버 로그용, 클라이언트는 `client_safe_message` 만 |
| `IllegalArgumentException` | `BAD_PARAMETER` | 400 | B3: 매퍼가 아닌 호출자의 일반 예외 |
| `ConstraintViolationException` | `VALIDATION_FAILED` | 400 | field/message violation 리스트를 details 로 |
| `ConstraintViolationException` | `VALIDATION_FAILED` | 400 | 정규화된 field + allowlisted reason code/fixed message 리스트를 details 로. interpolated message와 iterable key/index는 미노출 |
| `MethodArgumentTypeMismatchException` | `BAD_PARAMETER` | 400 | expectedType 을 details 로 |
| `InvalidBearerTokenException` | `INVALID_TOKEN` | 코드 상태 | |
| `AuthenticationException` | `UNAUTHENTICATED` | 코드 상태 | |
@@ -190,9 +209,9 @@ arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지
| `HttpMediaTypeNotSupportedException` | `UNSUPPORTED_MEDIA_TYPE` | 415 | D9: 요청 본문 형식 미지원 — 406 과 구별 |
| `MaxUploadSizeExceededException` | `PAYLOAD_TOO_LARGE` | 413 | D8: 과대 본문은 envelope 내 413, raw 500 금지. 멀티파트 전용 413(`UPLOAD_SIZE_EXCEEDED`)은 이 영역의 책임 — 병합 후 정제 |
| `HttpMediaTypeNotAcceptableException` | `NOT_ACCEPTABLE` | 406 | D9: Accept 에 맞는 표현 없음 — 415 와 구별(합치면 RFC 9110 의미론 상실) |
| `MethodArgumentNotValidException` | `VALIDATION_FAILED` | 코드 상태 | field/rejectedValue/message 리스트를 details 로 |
| `MethodArgumentNotValidException` | `VALIDATION_FAILED` | 코드 상태 | 정규화된 field + allowlisted reason code/fixed message 리스트를 details 로. rejectedValue/defaultMessage/iterable key/index는 secret/PII 가능성이 있어 미노출 |
| `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 |
| `NoHandlerFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | |
| `NoHandlerFoundException`, `NoResourceFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | controller/static-resource 어느 404 경로도 같은 Envelope를 사용하고 raw request URL을 echo하지 않음 |
| `Exception` (catch-all) | `INTERNAL_ERROR` | 500 | span 에러 기록 + "Internal server error" 고정 메시지 |
### ErrorResponseFactory
+38
View File
@@ -16,7 +16,15 @@ dependencies {
// never a hand-maintained stale schema). The release-blocking drift gate is
// owned by feature-contract-verification-test-suite (planned).
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
// WebApplicationType deduction keeps resolving SERVLET; the reactive handlers are wired only
// when the fileserver reactive profile is selected.
implementation 'org.springframework:spring-webflux'
implementation 'io.projectreactor:reactor-core'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'io.projectreactor:reactor-test'
}
tasks.register('jpaPersistenceRedactionContractTest', Test) {
@@ -34,3 +42,33 @@ tasks.register('jpaPersistenceRedactionContractTest', Test) {
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.named('test') {
useJUnitPlatform {
excludeTags 'security-boundary'
}
}
tasks.register('webSecurityBoundaryTest', Test) {
group = 'verification'
description = 'Runs hermetic JWT/JWKS and CORS filter-boundary contracts with no skips.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'security-boundary'
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
shouldRunAfter tasks.named('test')
jvmArgs '-Duser.timezone=UTC'
afterSuite { descriptor, result ->
if (descriptor.parent == null && result.skippedTestCount > 0) {
throw new GradleException(
"webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}")
}
}
}
tasks.named('check') {
dependsOn tasks.named('webSecurityBoundaryTest')
}
+5 -1
View File
@@ -50,6 +50,8 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -104,7 +106,7 @@ org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -120,6 +122,7 @@ org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -174,6 +177,7 @@ org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testComp
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
@@ -1,6 +1,7 @@
package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@@ -13,10 +14,13 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoderInitializationException;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.SupplierJwtDecoder;
import org.springframework.web.client.RestClientException;
/**
* Custom {@link JwtDecoder} for the resource server with an explicit validator chain: timestamp
@@ -34,13 +38,35 @@ public class JwtDecoderConfig {
@Bean
public JwtDecoder jwtDecoder(SecuritySettings settings) {
// Lazy: JWKS discovery happens on first decode, not at startup.
return new SupplierJwtDecoder(
() -> {
NimbusJwtDecoder decoder =
NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build();
decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience()));
return decoder;
});
SupplierJwtDecoder lazyDecoder =
new SupplierJwtDecoder(
() -> {
NimbusJwtDecoder decoder =
NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build();
decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience()));
return decoder;
});
return token -> {
try {
return lazyDecoder.decode(token);
} catch (JwtDecoderInitializationException exception) {
if (causedByExternalKeyService(exception)) {
throw new AuthenticationKeyServiceUnavailableException(exception);
}
throw new AuthenticationDecoderMisconfigurationException(exception);
}
};
}
private static boolean causedByExternalKeyService(Throwable failure) {
Throwable current = failure;
for (int depth = 0; current != null && depth < 32; depth++) {
if (current instanceof RestClientException || current instanceof IOException) {
return true;
}
current = current.getCause();
}
return false;
}
/** The explicit validator chain: timestamp (60s skew) + issuer + optional audience. */
@@ -63,4 +89,22 @@ public class JwtDecoderConfig {
return OAuth2TokenValidatorResult.failure(error);
};
}
static final class AuthenticationKeyServiceUnavailableException extends JwtException {
private static final long serialVersionUID = 1L;
AuthenticationKeyServiceUnavailableException(Throwable cause) {
super("Authentication key service unavailable", cause);
}
}
static final class AuthenticationDecoderMisconfigurationException extends JwtException {
private static final long serialVersionUID = 1L;
AuthenticationDecoderMisconfigurationException(Throwable cause) {
super("Authentication decoder configuration is invalid", cause);
}
}
}
@@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -32,7 +33,7 @@ public class JwtToAuthenticatedPrincipalConverter
AuthenticatedPrincipal principal = new AuthenticatedPrincipal(jwt.getSubject(), email, roles);
Collection<GrantedAuthority> authorities =
roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase()))
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT)))
.collect(Collectors.toCollection(ArrayList::new));
return new AuthenticatedJwtToken(jwt, authorities, principal);
}
@@ -1,6 +1,10 @@
package dev.caskeleton.adapter.inbound.web.auth;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.ByteArrayInputStream;
@@ -24,7 +28,9 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SaveContextOnUpdateOrErrorResponseWrapper;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.web.util.WebUtils;
/**
* Stores only a bounded primitive authentication snapshot in {@link HttpSession}.
@@ -46,13 +52,34 @@ final class PrimitiveSessionSecurityContextRepository implements SecurityContext
private static final int MAXIMUM_AUTHORITIES = 128;
@Override
@SuppressWarnings("deprecation")
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
return load(requestResponseHolder.getRequest());
HttpServletRequest request = requestResponseHolder.getRequest();
SecurityContext context = load(request);
HttpServletResponse response = requestResponseHolder.getResponse();
if (response != null) {
CommitSaveResponseWrapper wrappedResponse = new CommitSaveResponseWrapper(response, request);
wrappedResponse.setSecurityContextHolderStrategy(
SecurityContextHolder.getContextHolderStrategy());
requestResponseHolder.setResponse(wrappedResponse);
requestResponseHolder.setRequest(new AsyncAwareRequestWrapper(request, wrappedResponse));
}
return context;
}
@Override
public void saveContext(
SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
CommitSaveResponseWrapper wrapper =
WebUtils.getNativeResponse(response, CommitSaveResponseWrapper.class);
if (wrapper != null) {
wrapper.reconcileFinalContext(context);
return;
}
saveSnapshot(context, request);
}
private static void saveSnapshot(SecurityContext context, HttpServletRequest request) {
Objects.requireNonNull(request, "request");
Authentication authentication = context == null ? null : context.getAuthentication();
if (authentication == null
@@ -237,6 +264,51 @@ final class PrimitiveSessionSecurityContextRepository implements SecurityContext
return new IllegalArgumentException("security context snapshot is corrupt or incompatible");
}
@SuppressWarnings("deprecation")
private static final class CommitSaveResponseWrapper
extends SaveContextOnUpdateOrErrorResponseWrapper {
private final HttpServletRequest request;
private CommitSaveResponseWrapper(HttpServletResponse response, HttpServletRequest request) {
super(response, true);
this.request = request;
}
@Override
protected void saveContext(SecurityContext context) {
saveSnapshot(context, request);
}
private void reconcileFinalContext(SecurityContext context) {
saveContext(context);
}
}
@SuppressWarnings("deprecation")
private static final class AsyncAwareRequestWrapper extends HttpServletRequestWrapper {
private final CommitSaveResponseWrapper response;
private AsyncAwareRequestWrapper(
HttpServletRequest request, CommitSaveResponseWrapper response) {
super(request);
this.response = response;
}
@Override
public AsyncContext startAsync() {
response.disableSaveOnResponseCommitted();
return super.startAsync();
}
@Override
public AsyncContext startAsync(ServletRequest request, ServletResponse response) {
this.response.disableSaveOnResponseCommitted();
return super.startAsync(request, response);
}
}
private static final class PrimitiveAuthentication {
private final String principalId;
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.web.auth;
import java.util.List;
import java.util.Objects;
/**
* A path that needs more than authentication.
*
* <p>The base chain ends in {@code anyRequest().authenticated()}, which is the right default for a
* data plane and the wrong one for a management plane: it makes every authenticated caller a
* potential administrator, and an application-level policy consulted later cannot recover from a
* transport that already let the request through.
*
* <p>Modules that own a privileged surface contribute one of these instead of assembling a second
* filter chain. A second chain would have to restate the whole authentication mechanism — JWT
* decoding, session handling, the envelope entry point — and any drift between the two copies is a
* silent authorization hole.
*
* @param pathPattern Ant-style pattern the rule applies to, for example {@code /internal/x/**}
* @param requiredAuthorities any one of which admits the request; never empty
*/
public record RestrictedPathRule(String pathPattern, List<String> requiredAuthorities) {
public RestrictedPathRule {
Objects.requireNonNull(pathPattern, "pathPattern");
Objects.requireNonNull(requiredAuthorities, "requiredAuthorities");
if (pathPattern.isBlank()) {
throw new IllegalArgumentException("pathPattern must be non-blank");
}
if (requiredAuthorities.isEmpty()) {
throw new IllegalArgumentException(
"requiredAuthorities must not be empty: a rule that requires nothing is weaker than the "
+ "authenticated default it replaces");
}
requiredAuthorities = List.copyOf(requiredAuthorities);
}
String[] authorities() {
return requiredAuthorities.toArray(new String[0]);
}
}
@@ -5,9 +5,11 @@ import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
@@ -67,18 +69,27 @@ public class SecurityConfig {
AuthenticationEntryPoint authenticationEntryPoint,
AccessDeniedHandler accessDeniedHandler,
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
sessionSecurityContextRepository)
sessionSecurityContextRepository,
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths)
throws Exception {
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
http.cors(c -> c.configurationSource(corsConfigurationSource()))
// Disable Spring Security's default Cache-Control writer; CacheControlFilter
// owns the cache header policy. See README for the design rationale.
.headers(headers -> headers.cacheControl(cache -> cache.disable()))
// This is an API boundary: never persist framework SavedRequest graphs in a session.
.requestCache(cache -> cache.disable())
.authorizeHttpRequests(
auth -> {
if (publicPaths.length > 0) {
auth.requestMatchers(publicPaths).permitAll();
}
// Ordered before the authenticated catch-all: a management path must be refused at
// the transport, not by an application policy the request has already passed.
for (RestrictedPathRule rule : restricted) {
auth.requestMatchers(rule.pathPattern()).hasAnyAuthority(rule.authorities());
}
auth.anyRequest().authenticated();
})
// The entry point and access-denied handler are set on both exceptionHandling and
@@ -97,6 +108,7 @@ public class SecurityConfig {
oauth
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.withObjectPostProcessor(forwardServiceFailuresTo(authenticationEntryPoint))
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
} else {
SecuritySettings.SessionCookieSettings sessionSettings = securitySettings.session();
@@ -144,4 +156,17 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", cfg);
return source;
}
private static ObjectPostProcessor<BearerTokenAuthenticationFilter> forwardServiceFailuresTo(
AuthenticationEntryPoint authenticationEntryPoint) {
return new ObjectPostProcessor<>() {
@Override
public <O extends BearerTokenAuthenticationFilter> O postProcess(O filter) {
filter.setAuthenticationFailureHandler(
(request, response, exception) ->
authenticationEntryPoint.commence(request, response, exception));
return filter;
}
};
}
}
@@ -19,11 +19,11 @@ public class SecurityErrorClassifier {
/** Classifies an authentication (401-family) failure reaching the AuthenticationEntryPoint. */
public OperationalError classifyAuthentication(AuthenticationException ex) {
OperationalError byCause = classifyByCause(ex.getCause());
if (byCause != null) {
return byCause;
}
if (ex instanceof OAuth2AuthenticationException oauth) {
OperationalError byCause = classifyByCause(oauth.getCause());
if (byCause != null) {
return byCause;
}
OperationalError byError = classifyByText(describe(oauth.getError()));
return byError != null ? byError : OperationalError.AUTH_TOKEN_MALFORMED;
}
@@ -43,6 +43,12 @@ public class SecurityErrorClassifier {
if (cause == null) {
return null;
}
if (cause instanceof JwtDecoderConfig.AuthenticationKeyServiceUnavailableException) {
return OperationalError.AUTH_JWKS_UNAVAILABLE;
}
if (cause instanceof JwtDecoderConfig.AuthenticationDecoderMisconfigurationException) {
return OperationalError.INTERNAL_AUTH_MISCONFIGURATION;
}
if (cause instanceof JwtValidationException validation) {
// A JWKS retrieval failure can surface wrapped in validation errors too.
OperationalError fromText = null;
@@ -78,12 +84,12 @@ public class SecurityErrorClassifier {
if (m.contains("aud claim") || m.contains("audience")) {
return OperationalError.AUTH_AUDIENCE_MISMATCH;
}
if (m.contains("signature") || m.contains("signed jwt rejected")) {
return OperationalError.AUTH_TOKEN_INVALID_SIGNATURE;
}
if (m.contains("kid") || m.contains("matching key") || m.contains("key id")) {
return OperationalError.AUTH_KID_UNKNOWN;
}
if (m.contains("signature") || m.contains("signed jwt rejected")) {
return OperationalError.AUTH_TOKEN_INVALID_SIGNATURE;
}
if (m.contains("malformed")
|| m.contains("invalid jwt")
|| m.contains("invalid compact")
@@ -36,12 +36,45 @@ public final class ETags {
return true;
}
String target = opaque(etag);
for (String candidate : trimmed.split(",")) {
if (opaque(candidate).equals(target)) {
return true;
int candidateStart = 0;
boolean inQuotes = false;
boolean matched = false;
for (int index = 0; index < trimmed.length(); index++) {
char current = trimmed.charAt(index);
if (current == '"') {
inQuotes = !inQuotes;
} else if (current == ',' && !inQuotes) {
String candidate = trimmed.substring(candidateStart, index);
if (!isWellFormedCandidate(candidate)) {
return false;
}
matched |= opaque(candidate).equals(target);
candidateStart = index + 1;
}
}
return false;
if (inQuotes) {
return false;
}
String candidate = trimmed.substring(candidateStart);
if (!isWellFormedCandidate(candidate)) {
return false;
}
return matched || opaque(candidate).equals(target);
}
private static boolean isWellFormedCandidate(String raw) {
String value = raw.trim();
if (value.startsWith("W/")) {
value = value.substring(2).trim();
}
int firstQuote = value.indexOf('"');
if (firstQuote < 0) {
return true;
}
return firstQuote == 0
&& value.length() >= 2
&& value.charAt(value.length() - 1) == '"'
&& value.substring(1, value.length() - 1).indexOf('"') < 0;
}
/** Strips the {@code W/} weak marker and surrounding double quotes. */
@@ -19,6 +19,9 @@ import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class OpenApiContractConfig {
// Swagger's Components.getSchemas() is declared with a raw Schema, so a parameterized local would
// not compile against it. The rawness comes from the library, not from this code.
@SuppressWarnings("rawtypes")
@Bean
OpenApiCustomizer apiErrorDetailsObjectSchemaCustomizer() {
return openApi -> {
@@ -7,6 +7,40 @@ final class ClientSafeErrorMessages {
private ClientSafeErrorMessages() {}
static String forOperational(ApiErrorCode code) {
return switch (code.code()) {
case "MAPPING_FAILED" -> "Request data could not be mapped";
case "BAD_PARAMETER" -> "Request parameter is invalid";
case "VALIDATION_FAILED" -> "Request validation failed";
case "INVALID_TOKEN",
"AUTH_TOKEN_MALFORMED",
"AUTH_TOKEN_EXPIRED",
"AUTH_TOKEN_INVALID_SIGNATURE",
"AUTH_ISSUER_MISMATCH",
"AUTH_AUDIENCE_MISMATCH",
"AUTH_KID_UNKNOWN",
"AUTH_CLAIM_MAPPING_FAILED" ->
"Authentication token is invalid";
case "UNAUTHENTICATED", "AUTH_TOKEN_MISSING" -> "Authentication is required";
case "FORBIDDEN",
"AUTHZ_INSUFFICIENT_PERMISSION",
"AUTHZ_TENANT_MISMATCH",
"ACTUATOR_FORBIDDEN" ->
"Access is denied";
case "AUTH_JWKS_UNAVAILABLE" ->
"Authentication service temporarily unavailable, please retry";
case "PRECONDITION_FAILED" -> "Resource state changed; refresh and retry";
case "METHOD_NOT_ALLOWED" -> "HTTP method is not allowed for this route";
case "NOT_ACCEPTABLE" -> "No acceptable response representation is available";
case "PAYLOAD_TOO_LARGE" -> "Request payload exceeds the maximum allowed size";
case "UNSUPPORTED_MEDIA_TYPE" -> "Request content type is not supported";
case "ROUTE_NOT_FOUND" -> "Requested route was not found";
case "ADAPTER_DISABLED", "INTERNAL_ERROR", "INTERNAL_AUTH_MISCONFIGURATION" ->
"Internal server error";
default -> forPersistence(code.category());
};
}
static String forPersistence(Category category) {
return switch (category) {
case TRANSIENT_DEPENDENCY -> "Service temporarily unavailable, please retry later";
@@ -0,0 +1,130 @@
package dev.caskeleton.adapter.inbound.web.error;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Path;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.validation.FieldError;
/**
* Builds bounded validation details without reflecting rejected values or interpolated messages.
*/
final class ClientSafeValidationDetails {
private static final Rule INVALID = new Rule("INVALID", "Invalid value");
private static final Map<String, Rule> RULES =
Map.ofEntries(
Map.entry("NotNull", new Rule("NOT_NULL", "Required value is missing")),
Map.entry("NotBlank", new Rule("NOT_BLANK", "Value must not be blank")),
Map.entry("NotEmpty", new Rule("NOT_EMPTY", "Value must not be empty")),
Map.entry("Size", new Rule("SIZE", "Value size is outside the allowed range")),
Map.entry("Min", new Rule("MIN", "Value is below the allowed minimum")),
Map.entry("DecimalMin", new Rule("MIN", "Value is below the allowed minimum")),
Map.entry("Max", new Rule("MAX", "Value exceeds the allowed maximum")),
Map.entry("DecimalMax", new Rule("MAX", "Value exceeds the allowed maximum")),
Map.entry("Positive", new Rule("POSITIVE", "Value must be positive")),
Map.entry("PositiveOrZero", new Rule("POSITIVE_OR_ZERO", "Value must not be negative")),
Map.entry("Negative", new Rule("NEGATIVE", "Value must be negative")),
Map.entry("NegativeOrZero", new Rule("NEGATIVE_OR_ZERO", "Value must not be positive")),
Map.entry("Pattern", new Rule("PATTERN", "Value has an invalid format")),
Map.entry("Email", new Rule("EMAIL", "Value has an invalid format")),
Map.entry("Past", new Rule("PAST", "Value must be in the past")),
Map.entry(
"PastOrPresent", new Rule("PAST_OR_PRESENT", "Value must not be in the future")),
Map.entry("Future", new Rule("FUTURE", "Value must be in the future")),
Map.entry(
"FutureOrPresent", new Rule("FUTURE_OR_PRESENT", "Value must not be in the past")),
Map.entry("AssertTrue", new Rule("ASSERT_TRUE", "Value must be true")),
Map.entry("AssertFalse", new Rule("ASSERT_FALSE", "Value must be false")),
Map.entry("typeMismatch", new Rule("TYPE_MISMATCH", "Value has an invalid type")));
private ClientSafeValidationDetails() {}
static Map<String, Object> from(ConstraintViolation<?> violation) {
Annotation annotation = violation.getConstraintDescriptor().getAnnotation();
Rule rule = ruleFor(annotation == null ? null : annotation.annotationType().getSimpleName());
return detail(normalize(violation.getPropertyPath()), rule);
}
static Map<String, Object> from(FieldError fieldError) {
return detail(normalize(fieldError.getField()), ruleFor(fieldError.getCode()));
}
private static Map<String, Object> detail(String field, Rule rule) {
return Map.of("field", field, "code", rule.code(), "message", rule.message());
}
private static Rule ruleFor(String rawCode) {
if (rawCode == null || rawCode.isBlank()) {
return INVALID;
}
int qualifier = rawCode.indexOf('.');
String simpleCode = qualifier < 0 ? rawCode : rawCode.substring(0, qualifier);
return RULES.getOrDefault(simpleCode, INVALID);
}
private static String normalize(Path path) {
if (path == null) {
return "request";
}
List<String> names = new ArrayList<>();
for (Path.Node node : path) {
if (isPropertyName(node.getName())) {
names.add(node.getName());
}
}
return names.isEmpty() ? normalize(path.toString()) : String.join(".", names);
}
private static String normalize(String rawPath) {
if (rawPath == null || rawPath.isBlank()) {
return "request";
}
StringBuilder withoutIterableParts = new StringBuilder(rawPath.length());
int bracketDepth = 0;
for (int i = 0; i < rawPath.length(); i++) {
char current = rawPath.charAt(i);
if (current == '[') {
bracketDepth++;
} else if (current == ']') {
if (bracketDepth > 0) {
bracketDepth--;
}
} else if (bracketDepth == 0) {
withoutIterableParts.append(current);
}
}
List<String> names = new ArrayList<>();
int segmentStart = 0;
for (int i = 0; i <= withoutIterableParts.length(); i++) {
if (i == withoutIterableParts.length() || withoutIterableParts.charAt(i) == '.') {
String candidate = withoutIterableParts.substring(segmentStart, i);
if (isPropertyName(candidate)) {
names.add(candidate);
}
segmentStart = i + 1;
}
}
return names.isEmpty() ? "request" : String.join(".", names);
}
private static boolean isPropertyName(String candidate) {
if (candidate == null || candidate.isBlank() || candidate.length() > 128) {
return false;
}
if (!Character.isJavaIdentifierStart(candidate.charAt(0))) {
return false;
}
for (int i = 1; i < candidate.length(); i++) {
if (!Character.isJavaIdentifierPart(candidate.charAt(i))) {
return false;
}
}
return true;
}
private record Rule(String code, String message) {}
}
@@ -18,7 +18,6 @@ import dev.caskeleton.shared.error.PersistenceFailureException;
import dev.caskeleton.shared.response.Envelope;
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
import jakarta.validation.ConstraintViolationException;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -44,6 +43,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* Skeleton-wide base error → {@link Envelope} converter. Handles operational, transport, and
@@ -78,7 +78,10 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(MappingException.class)
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null);
return ErrorResponseFactory.envelope(
OperationalError.MAPPING_FAILED,
ClientSafeErrorMessages.forOperational(OperationalError.MAPPING_FAILED),
null);
}
/**
@@ -91,24 +94,24 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
"disabled optional adapter invoked at runtime: adapter={} (Layer 3 fail-fast)",
ex.adapterName(),
ex);
return ErrorResponseFactory.envelope(OperationalError.ADAPTER_DISABLED, ex.getMessage(), null);
return ErrorResponseFactory.envelope(
OperationalError.ADAPTER_DISABLED,
ClientSafeErrorMessages.forOperational(OperationalError.ADAPTER_DISABLED),
null);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Envelope<Void>> handleIllegalArgument(IllegalArgumentException ex) {
return ErrorResponseFactory.envelope(OperationalError.BAD_PARAMETER, ex.getMessage(), null);
return ErrorResponseFactory.envelope(
OperationalError.BAD_PARAMETER,
ClientSafeErrorMessages.forOperational(OperationalError.BAD_PARAMETER),
null);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Envelope<Void>> handleConstraintViolation(ConstraintViolationException ex) {
List<Map<String, Object>> violations =
ex.getConstraintViolations().stream()
.map(
v ->
Map.<String, Object>of(
"field", v.getPropertyPath().toString(),
"message", v.getMessage()))
.toList();
ex.getConstraintViolations().stream().map(ClientSafeValidationDetails::from).toList();
return ErrorResponseFactory.envelope(
OperationalError.VALIDATION_FAILED, "Request validation failed", violations);
}
@@ -120,19 +123,23 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
? null
: Map.of("expectedType", ex.getRequiredType().getSimpleName());
return ErrorResponseFactory.envelope(
OperationalError.BAD_PARAMETER,
"Parameter '" + ex.getName() + "' has invalid value '" + ex.getValue() + "'",
details);
OperationalError.BAD_PARAMETER, "Parameter '" + ex.getName() + "' is invalid", details);
}
@ExceptionHandler(InvalidBearerTokenException.class)
public ResponseEntity<Envelope<Void>> handleInvalidToken(InvalidBearerTokenException ex) {
return ErrorResponseFactory.envelope(OperationalError.INVALID_TOKEN, ex.getMessage(), null);
return ErrorResponseFactory.envelope(
OperationalError.INVALID_TOKEN,
ClientSafeErrorMessages.forOperational(OperationalError.INVALID_TOKEN),
null);
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Envelope<Void>> handleUnauthenticated(AuthenticationException ex) {
return ErrorResponseFactory.envelope(OperationalError.UNAUTHENTICATED, ex.getMessage(), null);
return ErrorResponseFactory.envelope(
OperationalError.UNAUTHENTICATED,
ClientSafeErrorMessages.forOperational(OperationalError.UNAUTHENTICATED),
null);
}
/**
@@ -142,15 +149,17 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Envelope<Void>> handleForbidden(AccessDeniedException ex) {
return ErrorResponseFactory.envelope(
ACCESS_DENIED_CLASSIFIER.classifyAccessDenied(ex), ex.getMessage(), null);
ApiErrorCode code = ACCESS_DENIED_CLASSIFIER.classifyAccessDenied(ex);
return ErrorResponseFactory.envelope(code, ClientSafeErrorMessages.forOperational(code), null);
}
/** Handles a failed {@code If-Match} precondition → 412 PRECONDITION_FAILED. */
@ExceptionHandler(PreconditionFailedException.class)
public ResponseEntity<Envelope<Void>> handlePreconditionFailed(PreconditionFailedException ex) {
return ErrorResponseFactory.envelope(
OperationalError.PRECONDITION_FAILED, ex.getMessage(), null);
OperationalError.PRECONDITION_FAILED,
ClientSafeErrorMessages.forOperational(OperationalError.PRECONDITION_FAILED),
null);
}
/**
@@ -161,7 +170,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
public ResponseEntity<Envelope<Void>> handlePageValidation(PageValidationException ex) {
Map<String, Object> details = Map.of("field", ex.field(), "code", ex.reasonCode());
return ErrorResponseFactory.envelope(
OperationalError.VALIDATION_FAILED, ex.getMessage(), details);
OperationalError.VALIDATION_FAILED, "Pagination parameter is invalid", details);
}
/**
@@ -173,7 +182,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
Map<String, Object> details = Map.of("field", "cursor", "code", "CURSOR_INVALID");
return ErrorResponseFactory.envelope(
OperationalError.VALIDATION_FAILED,
ex.getMessage() + "; re-request the first page",
"Cursor is invalid or expired; re-request the first page",
details);
}
@@ -280,14 +289,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
WebRequest request) {
List<Map<String, Object>> fields =
ex.getBindingResult().getFieldErrors().stream()
.map(
fe -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("field", fe.getField());
m.put("rejectedValue", String.valueOf(fe.getRejectedValue()));
m.put("message", fe.getDefaultMessage());
return m;
})
.map(ClientSafeValidationDetails::from)
.toList();
return new ResponseEntity<>(
ErrorResponseFactory.body(
@@ -329,7 +331,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
return new ResponseEntity<>(
ErrorResponseFactory.body(
OperationalError.METHOD_NOT_ALLOWED,
"HTTP method " + ex.getMethod() + " not allowed for this route",
ClientSafeErrorMessages.forOperational(OperationalError.METHOD_NOT_ALLOWED),
details),
responseHeaders,
HttpStatusCode.valueOf(OperationalError.METHOD_NOT_ALLOWED.httpStatus()));
@@ -351,7 +353,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
return new ResponseEntity<>(
ErrorResponseFactory.body(
OperationalError.UNSUPPORTED_MEDIA_TYPE,
"Content-Type " + ex.getContentType() + " is not supported",
ClientSafeErrorMessages.forOperational(OperationalError.UNSUPPORTED_MEDIA_TYPE),
details),
HttpStatusCode.valueOf(OperationalError.UNSUPPORTED_MEDIA_TYPE.httpStatus()));
}
@@ -395,10 +397,20 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleNoHandlerFoundException(
NoHandlerFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return routeNotFound();
}
@Override
protected ResponseEntity<Object> handleNoResourceFoundException(
NoResourceFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return routeNotFound();
}
private static ResponseEntity<Object> routeNotFound() {
return new ResponseEntity<>(
ErrorResponseFactory.body(
OperationalError.ROUTE_NOT_FOUND,
"No handler for " + ex.getHttpMethod() + " " + ex.getRequestURL(),
ClientSafeErrorMessages.forOperational(OperationalError.ROUTE_NOT_FOUND),
null),
HttpStatusCode.valueOf(OperationalError.ROUTE_NOT_FOUND.httpStatus()));
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.admin.FileserverAdminService;
import dev.caskeleton.application.fileserver.admin.ForceDeleteCommand;
import dev.caskeleton.application.fileserver.admin.IncompleteUploadView;
import dev.caskeleton.application.fileserver.admin.OrphanObject;
import dev.caskeleton.application.fileserver.admin.OrphanReconcileCommand;
import dev.caskeleton.application.fileserver.admin.OrphanReconcileReport;
import dev.caskeleton.application.fileserver.admin.RuntimeCapabilityReport;
import dev.caskeleton.application.fileserver.admin.StorageHealthReport;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.cleanup.CleanupBatchResult;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* The management plane, reachable only where it is explicitly enabled.
*
* <p>These routes live under {@code /internal/} and behind their own enablement property because
* they are not part of the public API surface: a deployment that exposes the public application
* port to the internet must be able to keep these off it entirely.
*
* <p>A reconcile without an explicit {@code dryRun=false} is always a dry run. That default lives
* here as well as in the command, because the most dangerous request is the one that omits a field.
*/
@RestController
@ConditionalOnProperty(
prefix = "app.fileserver-platform",
name = {"enabled", "admin.enabled"},
havingValue = "true")
public class FileserverAdminController {
private static final int DEFAULT_PAGE = 100;
private final FileserverAdminService adminService;
private final FileserverRequestContextFactory contextFactory;
public FileserverAdminController(
FileserverAdminService adminService, FileserverRequestContextFactory contextFactory) {
this.adminService = adminService;
this.contextFactory = contextFactory;
}
@GetMapping(
path = "/internal/fileserver/storage-health",
produces = MediaType.APPLICATION_JSON_VALUE)
public StorageHealthReport storageHealth() {
return adminService.storageHealth(contextFactory.current());
}
@GetMapping(
path = "/internal/fileserver/capabilities",
produces = MediaType.APPLICATION_JSON_VALUE)
public RuntimeCapabilityReport capabilities() {
return adminService.capabilities(contextFactory.current());
}
@GetMapping(path = "/internal/fileserver/orphans", produces = MediaType.APPLICATION_JSON_VALUE)
public List<OrphanObject> orphans(@RequestParam(name = "limit", defaultValue = "100") int limit) {
return adminService.orphans(limit, contextFactory.current());
}
@PostMapping(
path = "/internal/fileserver/orphans:reconcile",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public OrphanReconcileReport reconcileOrphans(@RequestBody OrphanReconcileHttpRequest request) {
return adminService.reconcileOrphans(toCommand(request), contextFactory.current());
}
@PostMapping(
path = "/internal/fileserver/files/{fileId}:reverify",
produces = MediaType.APPLICATION_JSON_VALUE)
public UploadedFileResponse reverify(@PathVariable("fileId") String fileId) {
return UploadedFileResponse.from(
adminService.reverify(FileId.parse(fileId), contextFactory.current()));
}
@PostMapping(
path = "/internal/fileserver/files/{fileId}:force-delete",
consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> forceDelete(
@PathVariable("fileId") String fileId, @Valid @RequestBody ForceDeleteHttpRequest request) {
adminService.forceDelete(
new ForceDeleteCommand(FileId.parse(fileId), request.reasonCode()),
contextFactory.current());
return ResponseEntity.accepted().build();
}
@GetMapping(
path = "/internal/fileserver/uploads/incomplete",
produces = MediaType.APPLICATION_JSON_VALUE)
public List<IncompleteUploadView> incompleteUploads(
@RequestParam(name = "limit", defaultValue = "100") int limit) {
return adminService.incompleteUploads(limit, contextFactory.current());
}
@PostMapping(
path = "/internal/fileserver/uploads:cleanup",
produces = MediaType.APPLICATION_JSON_VALUE)
public CleanupBatchResult cleanupUploads(
@RequestParam(name = "maxItems", defaultValue = "100") int maxItems,
@RequestParam(name = "maxBytes", defaultValue = "1073741824") long maxBytes) {
return adminService.cleanupUploads(maxItems, maxBytes, contextFactory.current());
}
/**
* Reads the wire request conservatively.
*
* <p>Every absent field resolves to the safe value: a dry run, the default page, and no
* fingerprints. Nothing here can be omitted into a destructive default.
*/
private static OrphanReconcileCommand toCommand(OrphanReconcileHttpRequest request) {
boolean dryRun = request.dryRun() == null || request.dryRun();
int limit = request.limit() == null ? DEFAULT_PAGE : request.limit();
if (dryRun) {
return OrphanReconcileCommand.dryRun(limit);
}
return new OrphanReconcileCommand(
false,
limit,
request.maxBytes() == null ? 1L << 30 : request.maxBytes(),
request.expectedFingerprints() == null ? List.of() : request.expectedFingerprints(),
request.reasonCode() == null ? "ORPHAN_APPLY" : request.reasonCode());
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** Wire form of a force delete; the reason is mandatory and is recorded in the audit trail. */
public record ForceDeleteHttpRequest(@NotBlank @Size(min = 8, max = 200) String reasonCode) {}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
import java.util.List;
/**
* Wire form of a reconcile request.
*
* <p>{@code dryRun} is a wrapper type on purpose: an absent field must mean "dry run", and a
* primitive would silently turn a missing value into {@code false}, which is an apply.
*/
public record OrphanReconcileHttpRequest(
Boolean dryRun,
Integer limit,
Long maxBytes,
List<String> expectedFingerprints,
String reasonCode) {}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.inbound.web.fileserver.config;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.error.TransferAdmissionRejectedException;
import dev.caskeleton.application.fileserver.api.error.TransferTimeoutException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* Admission control for blocking transfers.
*
* <p>The point of running transfers on their own bounded pool is not extra parallelism — the
* servlet thread blocks on the result either way — it is that the pool plus its bounded queue caps
* how many transfers can be in flight. Beyond that cap the request is rejected fast with a
* retryable {@code 429} instead of pinning a container thread until the container itself runs out.
*/
public final class BlockingTransferExecutor {
private final ThreadPoolTaskExecutor executor;
private final int awaitSeconds;
public BlockingTransferExecutor(ThreadPoolTaskExecutor executor, int awaitSeconds) {
this.executor = executor;
this.awaitSeconds = awaitSeconds;
}
/**
* Runs {@code work} on the transfer pool, translating saturation and timeout to design codes.
*
* <p>Submitted through {@link ThreadPoolTaskExecutor#submit} rather than {@code
* CompletableFuture.supplyAsync}. That distinction is the whole timeout contract: {@code
* CompletableFuture#cancel} ignores its {@code mayInterruptIfRunning} argument and never touches
* the worker thread, so a timed-out transfer used to return {@code 504} to the client while the
* worker kept streaming bytes into an abandoned response — holding a pool slot, a buffer and an
* open channel for as long as the copy took. A real {@code Future} interrupts, and an interrupted
* {@code FileChannel} closes itself, so the transfer actually stops.
*/
public <T> T call(Supplier<T> work) {
Future<T> future;
try {
future = executor.submit(work::get);
} catch (TaskRejectedException rejected) {
throw new TransferAdmissionRejectedException(
"transfer pool is saturated",
rejected,
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_ADMISSION_REJECTED, true));
}
try {
return future.get(awaitSeconds, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
future.cancel(true);
throw new TransferTimeoutException(
"transfer was interrupted before completing",
interrupted,
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_TIMEOUT, true));
} catch (TimeoutException timeout) {
// Interrupting is the point: the caller is about to answer 504, and a worker still copying
// into that response would keep a pool slot and an open channel for the rest of the transfer.
future.cancel(true);
throw new TransferTimeoutException(
"transfer did not complete within the configured budget",
timeout,
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_TIMEOUT, true));
} catch (ExecutionException failure) {
throw rethrow(failure);
}
}
/**
* Unwraps the worker failure.
*
* <p>A Fileserver failure keeps its own context — wrapping it in an execution exception here
* would lose the code, the ambiguity flag, and the correct status.
*/
private static RuntimeException rethrow(ExecutionException failure) {
Throwable cause = failure.getCause();
if (cause instanceof FileserverException fileserverFailure) {
return fileserverFailure;
}
if (cause instanceof RuntimeException runtime) {
return runtime;
}
if (cause instanceof Error error) {
throw error;
}
return new IllegalStateException("transfer failed", cause);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.fileserver.config;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import java.time.Duration;
import java.util.Objects;
/**
* Transport-side Fileserver settings.
*
* <p>These are the values a controller needs and that the application layer must not read for
* itself: the default namespace for an unscoped request, the upload resource lifetime, and the
* batch part ceiling.
*/
public record FileserverWebProperties(
StorageNamespace defaultNamespace,
Duration uploadTtl,
int maxBatchParts,
boolean contentLengthRequired) {
private static final int DESIGN_MAX_BATCH_PARTS = 16;
public FileserverWebProperties {
Objects.requireNonNull(defaultNamespace, "defaultNamespace");
Objects.requireNonNull(uploadTtl, "uploadTtl");
if (uploadTtl.isNegative() || uploadTtl.isZero()) {
throw new IllegalArgumentException("uploadTtl must be positive");
}
if (maxBatchParts < 1 || maxBatchParts > DESIGN_MAX_BATCH_PARTS) {
throw new IllegalArgumentException("maxBatchParts must be between 1 and 16");
}
}
/** Design standard profile: 1 h upload lifetime, 16 batch parts, length optional. */
public static FileserverWebProperties standard(StorageNamespace defaultNamespace) {
return new FileserverWebProperties(
defaultNamespace, Duration.ofHours(1), DESIGN_MAX_BATCH_PARTS, false);
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.inbound.web.fileserver.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* The bounded pool every blocking Fileserver transfer runs on.
*
* <p>The abort policy is the design decision, not a leftover default: silently running the transfer
* on the caller's thread would defeat the bound, and an unbounded queue would trade a fast {@code
* 429} for an eventual heap exhaustion. Rejection is translated into a retryable response by {@link
* BlockingTransferExecutor}.
*
* <p>The pool is decorated so a transfer running on a worker thread still carries the caller's
* correlation context; without it every transfer log line would be untraceable back to its request.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class MvcTransferExecutorConfiguration {
@Bean(destroyMethod = "shutdown")
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public ThreadPoolTaskExecutor fileserverTransferExecutor(
TransferExecutorProperties properties, TaskDecorator taskDecorator) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(taskDecorator);
executor.setCorePoolSize(properties.coreSize());
executor.setMaxPoolSize(properties.maxSize());
executor.setQueueCapacity(properties.queueCapacity());
executor.setThreadNamePrefix("fs-transfer-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(properties.awaitSeconds());
executor.initialize();
return executor;
}
@Bean
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public BlockingTransferExecutor blockingTransferExecutor(
ThreadPoolTaskExecutor fileserverTransferExecutor, TransferExecutorProperties properties) {
return new BlockingTransferExecutor(fileserverTransferExecutor, properties.awaitSeconds());
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.web.fileserver.config;
/**
* Bounds on the blocking transfer pool.
*
* <p>Every value is a hard bound. An unbounded queue would turn a saturation event into a heap
* exhaustion instead of a fast {@code 429}, which is why there is no "unlimited" option here.
*/
public record TransferExecutorProperties(
int coreSize, int maxSize, int queueCapacity, int awaitSeconds) {
public TransferExecutorProperties {
if (coreSize < 1 || maxSize < coreSize) {
throw new IllegalArgumentException("maxSize must be at least coreSize and both positive");
}
if (queueCapacity < 1) {
throw new IllegalArgumentException("queueCapacity must be positive");
}
if (awaitSeconds < 1) {
throw new IllegalArgumentException("awaitSeconds must be positive");
}
}
/** Design standard profile: core 8, max 32, queue 64. */
public static TransferExecutorProperties standard() {
return new TransferExecutorProperties(8, 32, 64, 300);
}
}
@@ -0,0 +1,165 @@
package dev.caskeleton.adapter.inbound.web.fileserver.controller;
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.http.MvcConditionalRequestFactory;
import dev.caskeleton.adapter.inbound.web.fileserver.http.MvcDownloadResponseWriter;
import dev.caskeleton.adapter.inbound.web.fileserver.http.ZeroCopyEligibility;
import dev.caskeleton.adapter.inbound.web.fileserver.nginx.NginxDownloadStrategy;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.ConditionalRequest;
import dev.caskeleton.application.fileserver.download.DownloadApplicationService;
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
import dev.caskeleton.application.fileserver.download.DownloadRequest;
import dev.caskeleton.application.fileserver.download.ZeroCopyTransferResult;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Metadata and content download endpoints.
*
* <p>GET and HEAD share one handler so their headers are identical by construction rather than by
* convention. Content is opened only after the decision says a body is expected, so a {@code 304},
* {@code 412}, or {@code 416} answer never reaches storage.
*/
@RestController
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class FileDownloadController {
private final DownloadApplicationService downloadService;
private final MvcConditionalRequestFactory conditionalFactory;
private final MvcDownloadResponseWriter responseWriter;
private final FileserverRequestContextFactory contextFactory;
private final BlockingTransferExecutor transferExecutor;
private final NginxDownloadStrategy delegationStrategy;
private final ZeroCopyEligibility zeroCopy;
/**
* The only constructor.
*
* <p>There is deliberately no shorter overload defaulting {@code zeroCopy} to disabled. Two
* constructors leave component scanning with no way to choose one, so the controller could not be
* instantiated at all; and a caller that took the short form would silently lose the optimization
* without saying so. Every construction site names its zero-copy policy.
*/
public FileDownloadController(
DownloadApplicationService downloadService,
MvcConditionalRequestFactory conditionalFactory,
MvcDownloadResponseWriter responseWriter,
FileserverRequestContextFactory contextFactory,
BlockingTransferExecutor transferExecutor,
NginxDownloadStrategy delegationStrategy,
ZeroCopyEligibility zeroCopy) {
this.downloadService = downloadService;
this.conditionalFactory = conditionalFactory;
this.responseWriter = responseWriter;
this.contextFactory = contextFactory;
this.transferExecutor = transferExecutor;
this.delegationStrategy = delegationStrategy;
this.zeroCopy = zeroCopy;
}
@GetMapping(path = "/v1/files/{fileId}", produces = MediaType.APPLICATION_JSON_VALUE)
public UploadedFileResponse describe(@PathVariable("fileId") String fileId) {
return UploadedFileResponse.from(
downloadService.describeFile(FileId.parse(fileId), contextFactory.current()));
}
@RequestMapping(
path = "/v1/files/{fileId}/content",
method = {RequestMethod.GET, RequestMethod.HEAD})
public void download(
@PathVariable("fileId") String fileId,
@RequestParam(name = "inline", required = false, defaultValue = "false") boolean inline,
HttpServletRequest request,
HttpServletResponse response) {
boolean headOnly = RequestMethod.HEAD.name().equalsIgnoreCase(request.getMethod());
ConditionalRequest conditional = conditionalFactory.from(request, headOnly);
RequestContext context = contextFactory.current();
DownloadDescriptor descriptor =
downloadService.describe(
new DownloadRequest(FileId.parse(fileId), conditional, inline), context);
responseWriter.writeHeaders(descriptor, response);
if (!descriptor.bodyExpected()) {
return;
}
// Delegation is decided only after authorization and the READY gate, so the internal redirect
// can only ever name content this caller was already allowed to read.
if (delegationStrategy.shouldDelegate(descriptor)) {
delegationStrategy.delegate(descriptor, response);
return;
}
// `isSecure` is read here, on the container thread, because the request may be recycled before
// the transfer task runs.
boolean secure = request.isSecure();
transferExecutor.call(() -> stream(descriptor, response, secure));
}
/**
* Writes the described bytes.
*
* <p>A full representation is treated as a single range so the read path has exactly one shape;
* there is no separate "whole file" branch that could drift from the partial one.
*
* <p>A large plaintext response is offered to storage for a direct kernel transfer first. The
* gateway may decline for any reason, and the streaming write below is then used unchanged — the
* response is identical either way, which is what keeps this an optimization rather than a second
* contract.
*
* <p>The fallback is taken only when nothing reached the socket. A transfer that moved some bytes
* and then stopped has already committed the response, and streaming the representation on top of
* it would send the prefix twice — a body that is longer than its own {@code Content-Length} and
* matches neither the length nor the digest the client was promised. That case aborts instead.
*/
private Void stream(DownloadDescriptor descriptor, HttpServletResponse response, boolean secure) {
if (!descriptor.isPartial() && descriptor.representation().length() == 0) {
// A zero-length representation has no range at all. Clamping produced 0..0 — a one-byte
// request over an empty object — which storage correctly refused as unsatisfiable, so a
// legitimately empty file answered 416 instead of an empty 200.
return null;
}
ByteRange range =
descriptor.isPartial()
? descriptor.singleRange()
: ByteRange.entire(descriptor.representation().length());
try {
if (zeroCopy.isEligible(true, range.length(), secure)) {
ZeroCopyTransferResult transfer =
downloadService.transferContent(
descriptor, range, Channels.newChannel(response.getOutputStream()));
if (transfer.isComplete()) {
return null;
}
if (!transfer.allowsFallback()) {
throw new UncheckedIOException(
new IOException(
"direct transfer stopped after "
+ transfer.transferredBytes()
+ " bytes; the response is already committed and must not be re-sent"));
}
}
try (ReadableByteChannel content = downloadService.openContent(descriptor, range)) {
responseWriter.writeBody(content, response);
}
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
return null;
}
}
@@ -0,0 +1,225 @@
package dev.caskeleton.adapter.inbound.web.fileserver.controller;
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.BatchUploadItemResult;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.BatchUploadResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.MultipartUploadRequestMapper;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.RawUploadRequestMapper;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.UploadIntent;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.error.FileTooLargeException;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
import dev.caskeleton.application.fileserver.upload.FileView;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
import dev.caskeleton.application.fileserver.upload.SingleShotUploadService;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* Streaming upload endpoints.
*
* <p>Bytes are never materialized: the raw path wraps the servlet input stream and the multipart
* path wraps each part's stream, so a 2 GiB upload costs a bounded buffer rather than 2 GiB of
* heap. Every transfer goes through the bounded transfer pool, which turns overload into a fast
* retryable rejection instead of container-thread exhaustion.
*/
@RestController
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class FileUploadController {
private final SingleShotUploadService uploadService;
private final RawUploadRequestMapper rawMapper;
private final MultipartUploadRequestMapper multipartMapper;
private final FileserverRequestContextFactory contextFactory;
private final BlockingTransferExecutor transferExecutor;
private final FileserverWebProperties properties;
private final Clock clock;
public FileUploadController(
SingleShotUploadService uploadService,
RawUploadRequestMapper rawMapper,
MultipartUploadRequestMapper multipartMapper,
FileserverRequestContextFactory contextFactory,
BlockingTransferExecutor transferExecutor,
FileserverWebProperties properties,
Clock clock) {
this.uploadService = uploadService;
this.rawMapper = rawMapper;
this.multipartMapper = multipartMapper;
this.contextFactory = contextFactory;
this.transferExecutor = transferExecutor;
this.properties = properties;
this.clock = clock;
}
/** Raw streaming upload; the whole request body is the file. */
// The channel wraps the servlet request body. Closing it would close the container's input
// stream, which the container owns and reuses for keep-alive; the upload must read the body
// and leave the stream alone.
@SuppressWarnings("resource")
@PostMapping(path = "/v1/files:raw", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UploadedFileResponse> uploadRaw(HttpServletRequest request)
throws IOException {
UploadIntent intent = rawMapper.map(request);
RequestContext context = contextFactory.current();
InputStream body = request.getInputStream();
FileView view =
transferExecutor.call(
() ->
upload(
intent,
Channels.newChannel(body),
declaredLength(intent),
context,
UploadProtocol.RAW));
return created(view);
}
/** Multipart single upload. */
@PostMapping(
path = "/v1/files",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UploadedFileResponse> uploadMultipart(
@RequestParam("file") MultipartFile file) throws IOException {
UploadIntent intent = multipartMapper.map(file);
RequestContext context = contextFactory.current();
// Closed on every path, including a rejection thrown inside the transfer. A multipart part is
// backed by a temporary file or a buffer the container only releases when the stream is closed.
try (InputStream body = file.getInputStream()) {
ReadableByteChannel content = Channels.newChannel(body);
FileView view =
transferExecutor.call(
() -> upload(intent, content, file.getSize(), context, UploadProtocol.MULTIPART));
return created(view);
}
}
/**
* Bounded multi-file upload.
*
* <p>The batch is explicitly non-atomic: each part is an independent file, a failure never rolls
* back a sibling that already succeeded, and the response is always {@code 200} carrying the
* ordered per-part outcome.
*/
@PostMapping(
path = "/v1/files:batch",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<BatchUploadResponse> uploadBatch(
@RequestParam("files") List<MultipartFile> files) {
requirePartCountWithinPolicy(files.size());
RequestContext context = contextFactory.current();
List<BatchUploadItemResult> results = new ArrayList<>(files.size());
for (int index = 0; index < files.size(); index++) {
results.add(uploadPart(files.get(index), index, context));
}
return ResponseEntity.ok(new BatchUploadResponse(results));
}
/**
* Uploads one batch part.
*
* <p>A part failure is converted to a per-part problem instead of aborting the request, which is
* what makes the endpoint's non-atomic contract observable rather than merely documented.
*/
private BatchUploadItemResult uploadPart(MultipartFile part, int index, RequestContext context) {
String clientPartId = partId(part, index);
try (InputStream body = part.getInputStream()) {
UploadIntent intent = multipartMapper.map(part);
ReadableByteChannel content = Channels.newChannel(body);
FileView view =
transferExecutor.call(
() -> upload(intent, content, part.getSize(), context, UploadProtocol.BATCH));
return BatchUploadItemResult.accepted(clientPartId, view);
} catch (FileserverException failure) {
return BatchUploadItemResult.rejected(clientPartId, failure.code());
} catch (IOException failure) {
return BatchUploadItemResult.rejected(clientPartId, FileserverErrorCode.STORAGE_UNAVAILABLE);
}
}
/**
* Records the protocol the request actually used.
*
* <p>Every endpoint previously persisted {@code RAW}. The recorded protocol is what a resume, an
* audit, and a reconciliation read to decide how an upload was produced, so labelling a batch
* part as a raw upload makes all three describe something that never happened.
*/
private FileView upload(
UploadIntent intent,
ReadableByteChannel content,
long contentLength,
RequestContext context,
UploadProtocol protocol) {
CreateUploadRequest request =
new CreateUploadRequest(
properties.defaultNamespace(),
intent.originalFilename(),
intent.claimedMediaType(),
intent.declaredLength(),
intent.expectedSha256(),
protocol,
clock.instant().plus(properties.uploadTtl()));
return uploadService.upload(
request,
content,
contentLength,
new FinalizeUploadRequest(intent.expectedSha256(), false),
context);
}
/**
* Renders the completed upload.
*
* <p>READY is a finished object, so it answers {@code 201}; anything still under verification is
* {@code 202} with no public content behind it yet.
*/
private static ResponseEntity<UploadedFileResponse> created(FileView view) {
HttpStatus status = view.state() == FileState.READY ? HttpStatus.CREATED : HttpStatus.ACCEPTED;
return ResponseEntity.status(status)
.location(URI.create("/v1/files/" + view.fileId().canonicalText()))
.body(UploadedFileResponse.from(view));
}
private void requirePartCountWithinPolicy(int partCount) {
if (partCount > properties.maxBatchParts()) {
throw new FileTooLargeException(
"batch exceeds the configured maximum part count",
FileserverFailureContext.of(FileserverErrorCode.FILE_TOO_LARGE, false));
}
}
/** Stable per-part identity so a caller can correlate a result with what it sent. */
private static String partId(MultipartFile part, int index) {
String name = part.getOriginalFilename();
return name == null || name.isBlank() ? String.valueOf(index) : name;
}
private static long declaredLength(UploadIntent intent) {
return intent.declaredLength().isPresent() ? intent.declaredLength().getAsLong() : -1;
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
/**
* The draft-12 header and media-type vocabulary.
*
* <p>Deliberately separate from the tus vocabulary even where the names coincide: sharing the
* constants would couple a Stable protocol to an unratified one, so a draft revision could silently
* change tus behaviour.
*/
public final class Draft12Headers {
public static final String UPLOAD_OFFSET = "Upload-Offset";
public static final String UPLOAD_COMPLETE = "Upload-Complete";
public static final String UPLOAD_LIMIT = "Upload-Limit";
/** Media type a draft-12 append carries. */
public static final String PARTIAL_UPLOAD = "application/partial-upload";
private Draft12Headers() {}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
/**
* The draft's offset-mismatch problem document.
*
* <p>It reports both offsets so the client can resume without a second round trip. This shape is
* the draft's own and is intentionally not the Fileserver problem document: a draft revision must
* be able to change it without touching the Stable contract.
*/
public record Draft12OffsetProblem(
String type, String title, int status, long expectedOffset, long providedOffset) {
public static final String TYPE =
"https://iana.org/assignments/http-problem-types#mismatching-upload-offset";
public static Draft12OffsetProblem of(long expectedOffset, long providedOffset) {
return new Draft12OffsetProblem(
TYPE, "Mismatching upload offset", 409, expectedOffset, providedOffset);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
import java.time.Duration;
import java.util.Objects;
/**
* Settings for the experimental draft-12 protocol.
*
* <p>Disabled by default. An unratified protocol that shipped enabled would make every deployment
* carry a surface whose contract can change without notice.
*/
public record Draft12Properties(
boolean enabled, long maxSize, Duration uploadTtl, boolean interimResponsesSupported) {
public Draft12Properties {
Objects.requireNonNull(uploadTtl, "uploadTtl");
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize must be positive");
}
if (uploadTtl.isNegative() || uploadTtl.isZero()) {
throw new IllegalArgumentException("uploadTtl must be positive");
}
}
/** Disabled profile, which is the shipped default. */
public static Draft12Properties disabled() {
return new Draft12Properties(false, 100L * 1024 * 1024, Duration.ofHours(1), false);
}
}
@@ -0,0 +1,178 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.api.error.MalformedRequestException;
import dev.caskeleton.application.fileserver.api.error.UploadOffsetMismatchException;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import dev.caskeleton.application.fileserver.upload.AppendUploadResult;
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadService;
import dev.caskeleton.application.fileserver.upload.UploadApplicationService;
import dev.caskeleton.application.fileserver.upload.UploadSessionView;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.channels.Channels;
import java.time.Clock;
import java.util.Optional;
import java.util.OptionalLong;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* HTTP resumable uploads, draft-12. Experimental.
*
* <p>It shares no path, no header constant, and no response type with the tus adapter. That
* separation is the point: the draft is unratified, and a future revision must be able to change
* this surface without touching a Stable protocol that clients already depend on.
*
* <p>Only the researched part of the draft is implemented — {@code Upload-Offset}, {@code
* Upload-Complete}, the partial-upload media type, and the offset-mismatch problem type. Nothing is
* guessed from a later revision.
*/
@RestController
@ExperimentalApi(specification = "draft-ietf-httpbis-resumable-upload-12")
@ConditionalOnProperty(
prefix = "app.fileserver-platform",
name = {"enabled", "httpbis-draft12.enabled"},
havingValue = "true")
public class Draft12UploadController {
private static final String DRAFT_PATH = "/v1/experimental/draft12/uploads";
private final UploadApplicationService uploadService;
private final FinalizeUploadService finalizeService;
private final FileserverRequestContextFactory contextFactory;
private final BlockingTransferExecutor transferExecutor;
private final FileserverWebProperties webProperties;
private final Draft12Properties draftProperties;
private final Clock clock;
public Draft12UploadController(
UploadApplicationService uploadService,
FinalizeUploadService finalizeService,
FileserverRequestContextFactory contextFactory,
BlockingTransferExecutor transferExecutor,
FileserverWebProperties webProperties,
Draft12Properties draftProperties,
Clock clock) {
this.uploadService = uploadService;
this.finalizeService = finalizeService;
this.contextFactory = contextFactory;
this.transferExecutor = transferExecutor;
this.webProperties = webProperties;
this.draftProperties = draftProperties;
this.clock = clock;
}
@PostMapping(DRAFT_PATH)
public ResponseEntity<Void> create(HttpServletRequest request) {
UploadSessionView created =
uploadService.create(
new CreateUploadRequest(
webProperties.defaultNamespace(),
filename(request),
Optional.empty(),
declaredLength(request),
Optional.empty(),
UploadProtocol.HTTPBIS_DRAFT12,
clock.instant().plus(draftProperties.uploadTtl())),
contextFactory.current());
return ResponseEntity.created(URI.create(DRAFT_PATH + "/" + created.uploadId().canonicalText()))
.header(Draft12Headers.UPLOAD_OFFSET, "0")
.header(Draft12Headers.UPLOAD_LIMIT, "max-size=" + draftProperties.maxSize())
.build();
}
@PatchMapping(path = DRAFT_PATH + "/{uploadId}", consumes = Draft12Headers.PARTIAL_UPLOAD)
// The channel wraps the servlet request body. Closing it would close the container's input
// stream, which the container owns and reuses for keep-alive; the upload must read the body
// and leave the stream alone.
@SuppressWarnings("resource")
public ResponseEntity<Void> append(
@PathVariable("uploadId") String uploadId, HttpServletRequest request) throws IOException {
UploadId id = UploadId.parse(uploadId);
long expectedOffset = requiredOffset(request);
boolean complete = isComplete(request);
RequestContext context = contextFactory.current();
InputStream body = request.getInputStream();
long declared = request.getContentLengthLong();
AppendUploadResult appended =
transferExecutor.call(
() ->
uploadService.append(
id, expectedOffset, Channels.newChannel(body), declared, context));
if (complete) {
finalizeService.finalizeUpload(id, FinalizeUploadRequest.synchronousWithoutDigest(), context);
}
return ResponseEntity.noContent()
.header(Draft12Headers.UPLOAD_OFFSET, String.valueOf(appended.committedOffset()))
.header(Draft12Headers.UPLOAD_COMPLETE, complete ? "?1" : "?0")
.build();
}
/**
* Renders the draft's own offset-mismatch problem document.
*
* <p>The draft defines a specific problem type carrying both offsets; mapping this through the
* shared Fileserver problem handler would answer the right status with the wrong body.
*/
@ExceptionHandler(UploadOffsetMismatchException.class)
public ResponseEntity<Draft12OffsetProblem> offsetMismatch(
UploadOffsetMismatchException failure) {
return ResponseEntity.status(409)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.header(Draft12Headers.UPLOAD_OFFSET, String.valueOf(failure.currentOffset()))
.body(Draft12OffsetProblem.of(failure.currentOffset(), failure.expectedOffset()));
}
/**
* Reads the structured-field boolean {@code Upload-Complete}.
*
* <p>An absent header means the upload continues; only the explicit {@code ?1} form completes it,
* so a truncated request can never publish a partial object.
*/
private static boolean isComplete(HttpServletRequest request) {
return "?1".equals(request.getHeader(Draft12Headers.UPLOAD_COMPLETE));
}
private static long requiredOffset(HttpServletRequest request) {
String header = request.getHeader(Draft12Headers.UPLOAD_OFFSET);
if (header == null || header.isBlank()) {
throw MalformedRequestException.of("draft-12 append requires Upload-Offset");
}
try {
long value = Long.parseLong(header.trim());
if (value < 0) {
throw new NumberFormatException("negative");
}
return value;
} catch (NumberFormatException malformed) {
throw MalformedRequestException.of("Upload-Offset is not a non-negative integer");
}
}
private static OptionalLong declaredLength(HttpServletRequest request) {
long length = request.getContentLengthLong();
return length < 0 ? OptionalLong.empty() : OptionalLong.of(length);
}
private static String filename(HttpServletRequest request) {
String header = request.getHeader("X-Filename");
return header == null || header.isBlank() ? "upload.bin" : header;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a type that implements an unratified specification.
*
* <p>An experimental protocol changes between drafts, so anything marked here may break on a
* specification revision even though this project's own contract did not change. The marker exists
* so that is visible in code review rather than discovered in production.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ExperimentalApi {
/** The exact draft this type implements. */
String specification();
}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
/**
* Failure of one batch part, in the same vocabulary the single-file endpoints use.
*
* <p>Only the stable code and its problem URN are exposed; the server-side message never crosses
* this boundary.
*/
public record BatchUploadItemProblem(String code, String type, int status) {
public static BatchUploadItemProblem of(FileserverErrorCode code) {
return new BatchUploadItemProblem(code.name(), code.problemType(), code.httpStatus());
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.upload.FileView;
/**
* Result of one batch part.
*
* <p>A batch is explicitly non-atomic, so each part reports its own outcome and a failure never
* rolls back a sibling that already succeeded.
*/
public record BatchUploadItemResult(
String clientPartId, String status, String fileId, BatchUploadItemProblem problem) {
public static BatchUploadItemResult accepted(String clientPartId, FileView view) {
return new BatchUploadItemResult(
clientPartId, view.state().name(), view.fileId().canonicalText(), null);
}
public static BatchUploadItemResult rejected(String clientPartId, FileserverErrorCode code) {
return new BatchUploadItemResult(
clientPartId, "REJECTED", null, BatchUploadItemProblem.of(code));
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import java.util.List;
/**
* Ordered per-part outcome of one batch upload.
*
* <p>The endpoint answers {@code 200} even when some parts failed: the batch has no request-wide
* atomicity, and pretending otherwise with a single status would hide the parts that succeeded.
*/
public record BatchUploadResponse(List<BatchUploadItemResult> results) {
public BatchUploadResponse {
results = List.copyOf(results);
}
/** True when every part succeeded, which the envelope layer reports as a plain success. */
public boolean fullySucceeded() {
return results.stream().allMatch(result -> result.problem() == null);
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* Target of a copy or move.
*
* <p>The namespace pattern is enforced at the boundary as syntax; the value object enforces it
* again as an invariant. {@code filename} is untrusted display data that the application layer
* sanitizes — it is never used to build a physical key.
*/
public record RelocateFileRequest(
@NotBlank @Pattern(regexp = "[a-z][a-z0-9-]{1,62}") String namespace, String filename) {}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import dev.caskeleton.application.fileserver.upload.FileView;
/**
* Wire projection of one completed upload.
*
* <p>It carries only what the public descriptor already exposes; no content key, physical path, or
* container temporary path ever appears here.
*/
public record UploadedFileResponse(
String fileId,
String state,
String filename,
String mediaType,
long size,
String sha256,
String etag) {
public static UploadedFileResponse from(FileView view) {
return new UploadedFileResponse(
view.fileId().canonicalText(),
view.state().name(),
view.descriptor().originalFilename(),
view.descriptor().mediaType(),
view.descriptor().size(),
view.descriptor().sha256(),
view.descriptor().strongEtag());
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.inbound.web.fileserver.http;
import dev.caskeleton.application.fileserver.api.transfer.ConditionalRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
/**
* Translates servlet headers into the transport-neutral conditional request.
*
* <p>Doing the translation here — and only here — is what lets MVC, WebFlux, and the delegation
* path share one decision implementation instead of each re-deriving the precedence rules.
*
* <p>An unparseable HTTP-date is treated as absent rather than as a failure, which is what RFC 9110
* requires: a malformed conditional header must be ignored, not turned into an error.
*/
public final class MvcConditionalRequestFactory {
public ConditionalRequest from(HttpServletRequest request, boolean headOnly) {
return new ConditionalRequest(
header(request, HttpHeaders.IF_MATCH),
header(request, HttpHeaders.IF_NONE_MATCH),
date(request, HttpHeaders.IF_MODIFIED_SINCE),
date(request, HttpHeaders.IF_UNMODIFIED_SINCE),
header(request, HttpHeaders.IF_RANGE),
header(request, HttpHeaders.RANGE),
headOnly);
}
private static Optional<String> header(HttpServletRequest request, String name) {
String value = request.getHeader(name);
return value == null || value.isBlank() ? Optional.empty() : Optional.of(value);
}
private static Optional<Instant> date(HttpServletRequest request, String name) {
Optional<String> raw = header(request, name);
if (raw.isEmpty()) {
return Optional.empty();
}
try {
return Optional.of(
ZonedDateTime.parse(raw.get(), DateTimeFormatter.RFC_1123_DATE_TIME).toInstant());
} catch (DateTimeParseException malformed) {
return Optional.empty();
}
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.inbound.web.fileserver.http;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.ReadableByteChannel;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import org.springframework.http.HttpHeaders;
/**
* Writes one download decision onto a servlet response.
*
* <p>GET and HEAD render exactly the same header set; only the body differs. That is deliberate: a
* HEAD whose {@code Content-Length} or {@code ETag} disagreed with the GET would make range
* resumption and cache revalidation unreliable.
*
* <p>The body is streamed through one bounded buffer, so a large object costs a fixed amount of
* heap rather than its own size.
*/
public final class MvcDownloadResponseWriter {
/** Header that stops a browser re-sniffing a declared media type. */
public static final String CONTENT_TYPE_OPTIONS = "X-Content-Type-Options";
private static final int BUFFER_BYTES = 64 * 1024;
private static final DateTimeFormatter HTTP_DATE =
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
/** Writes status and headers; {@code 304} deliberately carries no representation metadata. */
public void writeHeaders(DownloadDescriptor descriptor, HttpServletResponse response) {
response.setStatus(descriptor.status());
response.setHeader(HttpHeaders.ETAG, descriptor.representation().strongEtag());
response.setHeader(
HttpHeaders.LAST_MODIFIED,
HTTP_DATE.format(
ZonedDateTime.ofInstant(descriptor.representation().lastModified(), ZoneOffset.UTC)));
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
response.setHeader(HttpHeaders.CACHE_CONTROL, descriptor.cacheControl());
// Uploaded content never gets to describe itself: without nosniff a browser may re-interpret a
// declared octet-stream as HTML and execute it from this origin.
response.setHeader(CONTENT_TYPE_OPTIONS, "nosniff");
if (descriptor.status() == HttpServletResponse.SC_NOT_MODIFIED) {
return;
}
response.setHeader(HttpHeaders.CONTENT_TYPE, descriptor.representation().mediaType());
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, descriptor.contentDisposition());
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(payloadLength(descriptor)));
if (descriptor.isPartial()) {
ByteRange range = descriptor.singleRange();
response.setHeader(
HttpHeaders.CONTENT_RANGE,
"bytes "
+ range.startInclusive()
+ "-"
+ range.endInclusive()
+ "/"
+ descriptor.representation().length());
}
}
/** Streams {@code content} into the response through a single bounded buffer. */
public void writeBody(ReadableByteChannel content, HttpServletResponse response)
throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(BUFFER_BYTES);
try (ReadableByteChannel source = content) {
OutputStream target = response.getOutputStream();
while (source.read(buffer) >= 0) {
buffer.flip();
target.write(buffer.array(), buffer.arrayOffset(), buffer.limit());
buffer.clear();
}
target.flush();
}
}
/**
* Length the body would carry.
*
* <p>A HEAD reports the length it would have sent, so the header set matches the GET exactly even
* though no bytes follow.
*/
private static long payloadLength(DownloadDescriptor descriptor) {
if (descriptor.isPartial()) {
return descriptor.singleRange().length();
}
return descriptor.representation().length();
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.inbound.web.fileserver.http;
import org.springframework.http.ZeroCopyHttpOutputMessage;
import org.springframework.http.server.reactive.ServerHttpResponse;
/**
* Decides whether a response may be written with a kernel-level file transfer.
*
* <p>Zero copy is an optimization and never a contract change, so it is taken only when every
* precondition holds at once: the response implementation supports it, the body needs no
* transformation, and the connection is not encrypted in user space (TLS has to see the plaintext,
* so a {@code sendfile} would bypass the very layer that must transform it).
*/
public final class ZeroCopyEligibility {
private final boolean enabled;
private final long minimumBytes;
public ZeroCopyEligibility(boolean enabled, long minimumBytes) {
if (minimumBytes < 0) {
throw new IllegalArgumentException("minimumBytes must not be negative");
}
this.enabled = enabled;
this.minimumBytes = minimumBytes;
}
/** Design default: enabled above 16 MiB. */
public static ZeroCopyEligibility standard() {
return new ZeroCopyEligibility(true, 16L * 1024 * 1024);
}
public static ZeroCopyEligibility disabled() {
return new ZeroCopyEligibility(false, Long.MAX_VALUE);
}
public boolean isEligible(ServerHttpResponse response, long payloadBytes, boolean secure) {
return isEligible(supportsZeroCopy(response), payloadBytes, secure);
}
/**
* Decides eligibility from already-resolved facts.
*
* <p>Separating the capability probe from the policy keeps the policy testable without
* constructing a server response, and keeps the probe in exactly one place.
*/
public boolean isEligible(boolean responseCapable, long payloadBytes, boolean secure) {
return enabled && responseCapable && !secure && payloadBytes >= minimumBytes;
}
/** True when the response implementation can hand a file straight to the kernel. */
public static boolean supportsZeroCopy(ServerHttpResponse response) {
return response instanceof ZeroCopyHttpOutputMessage;
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.inbound.web.fileserver.lifecycle;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.RelocateFileRequest;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.lifecycle.CopyFileCommand;
import dev.caskeleton.application.fileserver.lifecycle.DeleteOutcome;
import dev.caskeleton.application.fileserver.lifecycle.FileLifecycleService;
import dev.caskeleton.application.fileserver.upload.FileView;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
/**
* Delete, copy, and move endpoints.
*
* <p>The status codes carry meaning that the client needs. A delete that still has physical content
* to reclaim answers {@code 202}, not {@code 204}: the file is already unreadable, but the
* operation is not finished, and a caller waiting for storage to be freed must be able to tell the
* difference.
*
* <p>These routes live outside the {@code controller} package because AIP-136's colon verb is
* applied to a path variable here — the copy and move paths append the verb to the file-id segment
* — which the repository's AIP-122 segment rule does not model. The design fixes those paths, so
* the code moves rather than the contract.
*/
@RestController
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class FileLifecycleController {
private final FileLifecycleService lifecycleService;
private final FileserverRequestContextFactory contextFactory;
public FileLifecycleController(
FileLifecycleService lifecycleService, FileserverRequestContextFactory contextFactory) {
this.lifecycleService = lifecycleService;
this.contextFactory = contextFactory;
}
@DeleteMapping("/v1/files/{fileId}")
public ResponseEntity<Void> delete(
@PathVariable("fileId") String fileId,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch) {
DeleteOutcome outcome =
lifecycleService.delete(FileId.parse(fileId), optional(ifMatch), contextFactory.current());
return outcome.physicalCleanupScheduled()
? ResponseEntity.accepted().build()
: ResponseEntity.noContent().build();
}
@PostMapping(
path = "/v1/files/{fileId}:copy",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UploadedFileResponse> copy(
@PathVariable("fileId") String fileId,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch,
@Valid @RequestBody RelocateFileRequest request) {
FileView copied =
lifecycleService.copy(
new CopyFileCommand(
FileId.parse(fileId),
StorageNamespace.of(request.namespace()),
Optional.ofNullable(request.filename()),
optional(ifMatch)),
contextFactory.current());
return ResponseEntity.accepted()
.location(URI.create("/v1/files/" + copied.fileId().canonicalText()))
.body(UploadedFileResponse.from(copied));
}
@PostMapping(
path = "/v1/files/{fileId}:move",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public UploadedFileResponse move(
@PathVariable("fileId") String fileId,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch,
@Valid @RequestBody RelocateFileRequest request) {
return UploadedFileResponse.from(
lifecycleService.move(
FileId.parse(fileId),
StorageNamespace.of(request.namespace()),
optional(ifMatch),
contextFactory.current()));
}
private static Optional<String> optional(String header) {
return header == null || header.isBlank() ? Optional.empty() : Optional.of(header);
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
import java.util.Optional;
import java.util.OptionalLong;
import org.springframework.web.multipart.MultipartFile;
/**
* Reads one multipart part's intent.
*
* <p>The part is never materialized here: {@code getBytes()} would pull the whole file into the
* heap, which is exactly the failure mode the streaming design exists to avoid. Only the part's
* declared metadata is read.
*/
public final class MultipartUploadRequestMapper {
private static final String FALLBACK_FILENAME = "upload.bin";
public UploadIntent map(MultipartFile part) {
return new UploadIntent(
filename(part), claimedMediaType(part), OptionalLong.of(part.getSize()), Optional.empty());
}
private static String filename(MultipartFile part) {
String submitted = part.getOriginalFilename();
return submitted == null || submitted.isBlank() ? FALLBACK_FILENAME : submitted;
}
private static Optional<String> claimedMediaType(MultipartFile part) {
String contentType = part.getContentType();
return contentType == null || contentType.isBlank()
? Optional.empty()
: Optional.of(contentType);
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.error.UnsupportedMediaTypeException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Reads a raw streaming upload's intent from the request headers.
*
* <p>The filename arrives in a header and is treated exactly like a multipart filename: untrusted
* display data that the application layer sanitizes. This mapper never opens the body, so a
* rejected request costs no bytes.
*/
public final class RawUploadRequestMapper {
public static final String FILENAME_HEADER = "X-Filename";
public static final String DIGEST_HEADER = "X-Content-Sha256";
private static final String FALLBACK_FILENAME = "upload.bin";
private final boolean contentLengthRequired;
public RawUploadRequestMapper(boolean contentLengthRequired) {
this.contentLengthRequired = contentLengthRequired;
}
public UploadIntent map(HttpServletRequest request) {
OptionalLong declaredLength = declaredLength(request);
if (contentLengthRequired && declaredLength.isEmpty()) {
throw new UnsupportedMediaTypeException(
"this profile requires a declared Content-Length",
FileserverFailureContext.of(FileserverErrorCode.CONTENT_LENGTH_REQUIRED, false));
}
return new UploadIntent(
filename(request), claimedMediaType(request), declaredLength, digest(request));
}
private static String filename(HttpServletRequest request) {
String header = request.getHeader(FILENAME_HEADER);
return header == null || header.isBlank() ? FALLBACK_FILENAME : header;
}
private static Optional<String> claimedMediaType(HttpServletRequest request) {
String contentType = request.getContentType();
return contentType == null || contentType.isBlank()
? Optional.empty()
: Optional.of(contentType);
}
/**
* Reads the declared length.
*
* <p>A chunked request has no length; that is legal and the streamed hard limit still applies, so
* an absent value is reported as absent rather than as zero.
*/
private static OptionalLong declaredLength(HttpServletRequest request) {
long length = request.getContentLengthLong();
return length < 0 ? OptionalLong.empty() : OptionalLong.of(length);
}
private static Optional<String> digest(HttpServletRequest request) {
String header = request.getHeader(DIGEST_HEADER);
if (header == null || header.isBlank()) {
return Optional.empty();
}
return Optional.of(header.trim().toLowerCase(Locale.ROOT));
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Transport-side reading of one upload's headers or part metadata.
*
* <p>It exists so the raw and multipart paths converge on one shape before anything reaches the
* application layer. Every field is untrusted client input; nothing here is used to build a
* physical key.
*/
public record UploadIntent(
String originalFilename,
Optional<String> claimedMediaType,
OptionalLong declaredLength,
Optional<String> expectedSha256) {
public UploadIntent {
Objects.requireNonNull(originalFilename, "originalFilename");
Objects.requireNonNull(claimedMediaType, "claimedMediaType");
Objects.requireNonNull(declaredLength, "declaredLength");
Objects.requireNonNull(expectedSha256, "expectedSha256");
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
import dev.caskeleton.application.fileserver.api.ContentKey;
import dev.caskeleton.application.fileserver.api.error.InvalidPathException;
import java.util.regex.Pattern;
/**
* Rebuilds the internal URI from a validated content key.
*
* <p>Nothing here concatenates client input. The key has already been through {@link ContentKey}'s
* character class, and this class re-checks the sharded shape before emitting a URI, because a
* header that reaches Nginx as an internal redirect is effectively a filesystem lookup: a traversal
* that survived to this point would be served, not rejected.
*/
public final class DefaultNginxInternalUriMapper implements NginxInternalUriMapper {
private static final Pattern SHARDED_KEY =
Pattern.compile("[a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}");
/** A well-formed key that names nothing; only the mapping's shape is under test. */
private static final String ATTESTATION_KEY = "00/00/startup-attestation";
private final NginxDelegationProperties properties;
public DefaultNginxInternalUriMapper(NginxDelegationProperties properties) {
this.properties = properties;
}
@Override
public String map(ContentKey key) {
return mapUnchecked(key.value());
}
/**
* Round-trips a representative key through the configured prefix and suffix.
*
* <p>A representative key rather than a real one: the attestation has to run before any object
* exists, and what it checks is the shape of the configuration, not the presence of content.
*/
@Override
public boolean attestMapping() {
try {
String uri = mapUnchecked(ATTESTATION_KEY);
return uri.startsWith("/")
&& uri.startsWith(properties.internalPrefix())
&& uri.endsWith(properties.objectSuffix())
&& uri.contains(ATTESTATION_KEY);
} catch (InvalidPathException misconfigured) {
return false;
}
}
@Override
public String mapUnchecked(String rawKey) {
if (rawKey == null || !SHARDED_KEY.matcher(rawKey).matches()) {
throw InvalidPathException.of("content key is not a valid sharded object key");
}
String uri = properties.internalPrefix() + rawKey + properties.objectSuffix();
if (uri.contains("..") || uri.contains("//") || uri.indexOf('\\') >= 0) {
throw InvalidPathException.of("internal uri failed its post-construction check");
}
return uri;
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Settings for handing large transfers to the front proxy.
*
* <p>The internal prefix must match an Nginx {@code location} marked {@code internal}; if it is
* not, the prefix becomes a publicly reachable path to raw content, so startup validates it rather
* than trusting configuration.
*/
public record NginxDelegationProperties(
boolean enabled, String internalPrefix, String objectSuffix, long minimumBytes) {
private static final Pattern SAFE_PREFIX = Pattern.compile("/[A-Za-z0-9_/-]{1,64}");
private static final Pattern SAFE_SUFFIX = Pattern.compile("(\\.[a-z0-9]{1,8})?");
public NginxDelegationProperties {
Objects.requireNonNull(internalPrefix, "internalPrefix");
Objects.requireNonNull(objectSuffix, "objectSuffix");
if (!SAFE_PREFIX.matcher(internalPrefix).matches() || !internalPrefix.endsWith("/")) {
throw new IllegalArgumentException("internalPrefix must be a safe rooted path ending in '/'");
}
if (!SAFE_SUFFIX.matcher(objectSuffix).matches()) {
throw new IllegalArgumentException("objectSuffix must be empty or a short lowercase suffix");
}
if (minimumBytes < 0) {
throw new IllegalArgumentException("minimumBytes must not be negative");
}
}
/** Design default: disabled, {@code /__files/} prefix, {@code .bin} objects, 16 MiB threshold. */
public static NginxDelegationProperties disabled() {
return new NginxDelegationProperties(false, "/__files/", ".bin", 16L * 1024 * 1024);
}
public static NginxDelegationProperties enabledWithDefaults() {
return new NginxDelegationProperties(true, "/__files/", ".bin", 16L * 1024 * 1024);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
import jakarta.servlet.http.HttpServletResponse;
/**
* Decides whether one authorized download is handed to the front proxy, and writes the handoff.
*
* <p>Delegation happens strictly <em>after</em> authorization and the READY gate, so the internal
* redirect can only ever name content the caller was already allowed to read. A partial or
* conditional answer is never delegated: the proxy would have to re-derive the range and validator
* decisions, and two implementations of that logic is exactly the drift this design forbids.
*/
public final class NginxDownloadStrategy {
/** Header Nginx consumes; it must never be copied through to the client. */
public static final String ACCEL_REDIRECT_HEADER = "X-Accel-Redirect";
private final NginxDelegationProperties properties;
private final NginxInternalUriMapper uriMapper;
public NginxDownloadStrategy(
NginxDelegationProperties properties, NginxInternalUriMapper uriMapper) {
this.properties = properties;
this.uriMapper = uriMapper;
}
/** True when this descriptor should be transferred by the proxy rather than in-process. */
public boolean shouldDelegate(DownloadDescriptor descriptor) {
return properties.enabled()
&& descriptor.bodyExpected()
&& !descriptor.isPartial()
&& descriptor.status() == HttpServletResponse.SC_OK
&& descriptor.representation().length() >= properties.minimumBytes();
}
/**
* Writes the handoff.
*
* <p>{@code Content-Length} is deliberately cleared: the proxy sets it from the file it actually
* sends, and a stale value from the metadata store would truncate or hang the response if the two
* ever disagreed.
*/
public void delegate(DownloadDescriptor descriptor, HttpServletResponse response) {
response.setHeader(ACCEL_REDIRECT_HEADER, uriMapper.map(descriptor.contentKey()));
response.setHeader("Content-Length", null);
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
import dev.caskeleton.application.fileserver.api.ContentKey;
/**
* Turns a server-generated content key into the internal URI the front proxy serves.
*
* <p>The result is always relative and always below the configured internal prefix. An absolute
* physical path never crosses this boundary — the proxy resolves the prefix to a filesystem root
* itself, so the application never has to disclose where content lives.
*/
public interface NginxInternalUriMapper {
String map(ContentKey key);
/**
* Maps a raw string, validating it first.
*
* <p>This exists because internal callers are exactly where an unvalidated key would otherwise
* slip through; it validates rather than trusting the caller.
*/
String mapUnchecked(String rawKey);
/**
* Proves the configured mapping actually produces a usable internal URI.
*
* <p>Called once at startup instead of reading a setting in which a deployment asserts its own
* correctness. The failure this catches is silent by nature: a prefix the proxy does not resolve
* makes the server answer {@code 200} with an empty body, so the client believes it received the
* file. Better to refuse to start.
*/
boolean attestMapping();
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Maps every Fileserver failure onto its design status and problem document.
*
* <p>It is ordered ahead of the base operational handler, whose catch-all would otherwise resolve
* these to a generic internal error and lose the code. The status comes from the error code itself,
* so Spring MVC, Spring WebFlux, and the Nginx delegation path cannot drift apart.
*
* <p>Two headers are part of the contract rather than decoration: {@code Retry-After} on a
* retryable rejection, and the unsatisfied-range form of {@code Content-Range} on {@code 416},
* which is how a client learns the real representation length.
*/
@RestControllerAdvice
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class FileserverExceptionHandler {
private final FileserverProblemFactory problemFactory;
public FileserverExceptionHandler(FileserverProblemFactory problemFactory) {
this.problemFactory = problemFactory;
}
@ExceptionHandler(FileserverException.class)
public ResponseEntity<FileserverProblem> handle(
FileserverException failure, HttpServletRequest request) {
FileserverProblem problem = problemFactory.create(failure.context(), request.getRequestURI());
ResponseEntity.BodyBuilder response =
ResponseEntity.status(problem.status()).contentType(MediaType.APPLICATION_PROBLEM_JSON);
// The header policy is shared with the reactive router; neither transport owns it.
FileserverProblemHeaders.of(failure).forEach(response::header);
return response.body(problem);
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* RFC 9457 problem document for a Fileserver failure.
*
* <p>This is a transport-owned record rather than the framework's problem type, and it carries only
* the stable code plus the correlation fields the client can act on. The server-side exception
* message, physical path, mount, scanner credential, and filename never appear here.
*
* <p>{@code ambiguous} and {@code reconciliationRequired} are exposed deliberately: a client that
* gets an ambiguous failure must not blindly retry, because the operation may already have taken
* effect.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FileserverProblem(
String type,
String title,
int status,
String code,
String detail,
String instance,
String traceId,
boolean retryable,
boolean ambiguous,
boolean reconciliationRequired,
String fileId,
String uploadId,
Long expectedOffset,
Long currentOffset,
String state) {}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import java.util.OptionalLong;
import org.slf4j.MDC;
/**
* Builds the wire problem document from a failure context.
*
* <p>Everything the client sees is derived from the context — never from the exception message — so
* no code path can accidentally widen what a failure discloses.
*/
public final class FileserverProblemFactory {
public FileserverProblem create(FileserverFailureContext context, String instance) {
FileserverErrorCode code = context.code();
return new FileserverProblem(
code.problemType(),
FileserverProblemTitles.titleOf(code),
code.httpStatus(),
code.name(),
FileserverProblemTitles.titleOf(code),
instance,
traceId(),
context.retryable(),
context.ambiguous(),
context.reconciliationRequired(),
context.fileId().map(fileId -> fileId.canonicalText()).orElse(null),
context.uploadId().map(uploadId -> uploadId.canonicalText()).orElse(null),
boxed(context.expectedOffset()),
boxed(context.currentOffset()),
context.currentState().map(Enum::name).orElse(null));
}
private static Long boxed(OptionalLong value) {
return value.isPresent() ? value.getAsLong() : null;
}
private static String traceId() {
String traceId = MDC.get(MdcKeys.TRACE_ID);
return traceId == null || traceId.isBlank() ? null : traceId;
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import dev.caskeleton.application.fileserver.api.error.RangeNotSatisfiableException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* The headers a Fileserver failure carries, owned once for every transport.
*
* <p>These are contract, not decoration. {@code Retry-After} is how a client learns that a
* rejection is temporary and roughly how temporary; without it a well-behaved client either retries
* immediately — turning a saturation signal into a stampede — or gives up on something that would
* have succeeded in a second. The unsatisfied-range form of {@code Content-Range} is how a client
* learns the real representation length after a {@code 416}.
*
* <p>The table lived inside the servlet advice, and the reactive router simply did not have one, so
* the same failure answered with different headers depending on which transport served it. A shared
* owner is the only arrangement in which that cannot silently happen again: neither transport
* decides anything, both ask.
*/
public final class FileserverProblemHeaders {
/**
* How long a client should wait, per code.
*
* <p>The values are deliberately small and different from each other: a saturated pool drains in
* about a second, a storage outage does not. One shared constant would tell the client nothing.
*/
private static final Map<FileserverErrorCode, Integer> RETRY_AFTER_SECONDS =
Map.of(
FileserverErrorCode.TRANSFER_ADMISSION_REJECTED, 1,
FileserverErrorCode.STORAGE_UNAVAILABLE, 30,
FileserverErrorCode.ATOMIC_PUBLISH_UNSUPPORTED, 30,
FileserverErrorCode.TRANSFER_TIMEOUT, 5,
FileserverErrorCode.FILE_NOT_READY, 2,
FileserverErrorCode.CONCURRENT_MODIFICATION, 1);
private FileserverProblemHeaders() {}
/**
* Headers this failure must carry, in insertion order.
*
* <p>Returned as plain strings so neither Spring MVC's nor WebFlux's header type appears in the
* shared policy — the two transports differ in how they apply headers, not in which ones apply.
*/
public static Map<String, String> of(FileserverException failure) {
Map<String, String> headers = new LinkedHashMap<>();
Integer retryAfter = RETRY_AFTER_SECONDS.get(failure.code());
// Only a failure the server itself called retryable advertises a retry: telling a client to
// come back after a permanent rejection is worse than saying nothing.
if (retryAfter != null && failure.context().retryable()) {
headers.put("Retry-After", String.valueOf(retryAfter));
}
if (failure instanceof RangeNotSatisfiableException unsatisfiable) {
headers.put("Content-Range", "bytes */" + unsatisfiable.representationLength());
}
return headers;
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import java.util.EnumMap;
import java.util.Map;
/**
* Client-safe wording for every failure code.
*
* <p>The server-side exception message is log-only, so the wire needs its own vocabulary. Keeping
* it in one table is what stops a future handler from quietly echoing {@code getMessage()} — which
* is how storage paths and scanner responses leak into responses.
*/
final class FileserverProblemTitles {
private static final Map<FileserverErrorCode, String> TITLES =
new EnumMap<>(
Map.ofEntries(
Map.entry(FileserverErrorCode.BAD_REQUEST, "Malformed request"),
Map.entry(FileserverErrorCode.UNAUTHENTICATED, "Authentication required"),
Map.entry(FileserverErrorCode.ACCESS_DENIED, "Access denied"),
Map.entry(FileserverErrorCode.FILE_NOT_FOUND, "File not found"),
Map.entry(FileserverErrorCode.FILE_ALREADY_EXISTS, "File already exists"),
Map.entry(FileserverErrorCode.FILE_NOT_READY, "File is not ready"),
Map.entry(FileserverErrorCode.UPLOAD_OFFSET_MISMATCH, "Upload offset mismatch"),
Map.entry(FileserverErrorCode.CONCURRENT_MODIFICATION, "Concurrent modification"),
Map.entry(FileserverErrorCode.UPLOAD_EXPIRED, "Upload resource has expired"),
Map.entry(FileserverErrorCode.CONTENT_LENGTH_REQUIRED, "Content-Length is required"),
Map.entry(FileserverErrorCode.PRECONDITION_FAILED, "Precondition failed"),
Map.entry(FileserverErrorCode.FILE_TOO_LARGE, "File is too large"),
Map.entry(FileserverErrorCode.QUOTA_EXCEEDED, "Storage quota exceeded"),
Map.entry(FileserverErrorCode.UNSUPPORTED_MEDIA_TYPE, "Unsupported media type"),
Map.entry(FileserverErrorCode.RANGE_NOT_SATISFIABLE, "Range not satisfiable"),
Map.entry(FileserverErrorCode.INTEGRITY_MISMATCH, "Content integrity mismatch"),
Map.entry(FileserverErrorCode.MALWARE_DETECTED, "Content was rejected"),
Map.entry(FileserverErrorCode.INVALID_PATH, "Invalid path"),
Map.entry(FileserverErrorCode.PATH_OUTSIDE_NAMESPACE, "Path outside namespace"),
Map.entry(FileserverErrorCode.TRANSFER_ADMISSION_REJECTED, "Too many transfers"),
Map.entry(FileserverErrorCode.PARTIAL_WRITE, "Transfer did not complete"),
Map.entry(
FileserverErrorCode.AMBIGUOUS_COMPLETION, "Completion could not be confirmed"),
Map.entry(
FileserverErrorCode.ATOMIC_PUBLISH_UNSUPPORTED,
"Storage cannot publish atomically"),
Map.entry(FileserverErrorCode.STORAGE_UNAVAILABLE, "Storage is unavailable"),
Map.entry(FileserverErrorCode.TRANSFER_TIMEOUT, "Transfer timed out"),
Map.entry(FileserverErrorCode.STORAGE_FULL, "Storage is full")));
private FileserverProblemTitles() {}
static String titleOf(FileserverErrorCode code) {
return TITLES.getOrDefault(code, "Request failed");
}
}
@@ -0,0 +1,150 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.nio.ByteBuffer;
import java.nio.channels.ReadableByteChannel;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import reactor.core.publisher.Flux;
/**
* Bridges a reactive body into the blocking channel the content store expects.
*
* <p>Two properties matter and are enforced structurally rather than by discipline. First, every
* pooled {@link DataBuffer} this subscriber observes is copied and released inside {@code onNext},
* so no buffer can survive a later cancellation or error path — there is no path where a pooled
* buffer is still owned by this class. Second, demand is replenished one item at a time as the
* reader consumes, so the number of buffers in flight never exceeds the configured prefetch no
* matter how fast the client sends.
*/
final class DataBufferByteChannel implements ReadableByteChannel, Subscriber<DataBuffer> {
/**
* Sentinel meaning the upstream finished.
*
* <p>It is a distinct type rather than an empty buffer so termination is decided by what the
* queue holds, never by comparing buffer identities.
*/
private static final Object END_OF_STREAM = new Object();
private final BlockingQueue<Object> ready = new LinkedBlockingQueue<>();
private final AtomicBoolean open = new AtomicBoolean(true);
private final int prefetch;
private volatile Subscription subscription;
private volatile Throwable failure;
private ByteBuffer current;
private DataBufferByteChannel(int prefetch) {
this.prefetch = prefetch;
}
/**
* Subscribes to {@code body} and exposes it as a blocking channel bounded by {@code prefetch}.
*/
static DataBufferByteChannel subscribeTo(Flux<DataBuffer> body, int prefetch) {
if (prefetch < 1) {
throw new IllegalArgumentException("prefetch must be positive");
}
DataBufferByteChannel channel = new DataBufferByteChannel(prefetch);
body.subscribe(channel);
return channel;
}
@Override
public void onSubscribe(Subscription subscription) {
this.subscription = subscription;
subscription.request(prefetch);
}
@Override
public void onNext(DataBuffer buffer) {
try {
ByteBuffer copy = ByteBuffer.allocate(buffer.readableByteCount());
buffer.toByteBuffer(copy);
copy.rewind();
ready.add(copy);
} finally {
DataBufferUtils.release(buffer);
}
}
@Override
public void onError(Throwable throwable) {
this.failure = throwable;
ready.add(END_OF_STREAM);
}
@Override
public void onComplete() {
ready.add(END_OF_STREAM);
}
@Override
public int read(ByteBuffer destination) throws IOException {
if (!open.get()) {
throw new IOException("channel is closed");
}
if (current == null || !current.hasRemaining()) {
current = nextChunk();
if (current == null) {
return -1;
}
}
int transferred = Math.min(destination.remaining(), current.remaining());
int limit = current.limit();
current.limit(current.position() + transferred);
destination.put(current);
current.limit(limit);
return transferred;
}
/**
* Waits for the next chunk and replenishes exactly one unit of demand.
*
* <p>Requesting only after a chunk has been consumed is what bounds the in-flight buffer count;
* an unconditional {@code request(Long.MAX_VALUE)} would let a fast client outrun the disk.
*/
private ByteBuffer nextChunk() throws IOException {
Object taken;
try {
taken = ready.take();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new InterruptedIOException("interrupted while waiting for the request body");
}
if (!(taken instanceof ByteBuffer chunk)) {
if (failure != null) {
throw new IOException("request body failed", failure);
}
return null;
}
Subscription pending = subscription;
if (pending != null) {
pending.request(1);
}
return chunk;
}
@Override
public boolean isOpen() {
return open.get();
}
@Override
public void close() {
if (open.compareAndSet(true, false)) {
Subscription current = subscription;
if (current != null) {
current.cancel();
}
ready.clear();
}
}
}
@@ -0,0 +1,146 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.ConditionalRequest;
import dev.caskeleton.application.fileserver.download.DownloadApplicationService;
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
import dev.caskeleton.application.fileserver.download.DownloadRequest;
import java.nio.channels.ReadableByteChannel;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Optional;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Reactive counterpart of the MVC download endpoints.
*
* <p>The decision comes from the same application service the servlet path uses, so status codes
* and headers cannot drift. Content is read on the dedicated I/O scheduler in bounded chunks, and
* every emitted buffer is released on cancellation — a client that disconnects mid-transfer must
* not leak the pooled buffers already in flight.
*/
public final class FileDownloadHandler {
private static final int CHUNK_BYTES = 64 * 1024;
private final DownloadApplicationService downloadService;
private final ReactiveDownloadResponseWriter responseWriter;
private final FileserverIoScheduler ioScheduler;
public FileDownloadHandler(
DownloadApplicationService downloadService,
ReactiveDownloadResponseWriter responseWriter,
FileserverIoScheduler ioScheduler) {
this.downloadService = downloadService;
this.responseWriter = responseWriter;
this.ioScheduler = ioScheduler;
}
public Mono<ServerResponse> describe(ServerRequest request) {
FileId fileId = FileId.parse(request.pathVariable("fileId"));
return Mono.fromCallable(() -> downloadService.describeFile(fileId, contextOf(request)))
.subscribeOn(ioScheduler.scheduler())
.flatMap(
view ->
ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(UploadedFileResponse.from(view)));
}
public Mono<ServerResponse> download(ServerRequest request) {
FileId fileId = FileId.parse(request.pathVariable("fileId"));
boolean headOnly = HttpMethod.HEAD.equals(request.method());
boolean inline = request.queryParam("inline").map(Boolean::parseBoolean).orElse(false);
RequestContext context = contextOf(request);
return Mono.fromCallable(
() ->
downloadService.describe(
new DownloadRequest(fileId, conditionalOf(request, headOnly), inline), context))
.subscribeOn(ioScheduler.scheduler())
.flatMap(descriptor -> respond(descriptor, headOnly));
}
private Mono<ServerResponse> respond(DownloadDescriptor descriptor, boolean headOnly) {
ServerResponse.BodyBuilder builder =
ServerResponse.status(descriptor.status())
.headers(headers -> headers.putAll(responseWriter.headersFor(descriptor)));
if (!descriptor.bodyExpected() || headOnly) {
return builder.build();
}
return builder.body(content(descriptor), DataBuffer.class);
}
/**
* Streams the described bytes with bounded demand.
*
* <p>{@code readByteChannel} closes the channel and releases every buffer it emitted when the
* subscriber cancels, which is the behaviour a disconnecting client depends on.
*/
private Flux<DataBuffer> content(DownloadDescriptor descriptor) {
ByteRange range = responseWriter.payloadRange(descriptor);
return DataBufferUtils.readByteChannel(
() -> openContent(descriptor, range),
DefaultDataBufferFactory.sharedInstance,
CHUNK_BYTES)
.subscribeOn(ioScheduler.scheduler());
}
private ReadableByteChannel openContent(DownloadDescriptor descriptor, ByteRange range) {
return downloadService.openContent(descriptor, range);
}
private static ConditionalRequest conditionalOf(ServerRequest request, boolean headOnly) {
HttpHeaders headers = request.headers().asHttpHeaders();
return new ConditionalRequest(
header(headers, HttpHeaders.IF_MATCH),
header(headers, HttpHeaders.IF_NONE_MATCH),
date(headers, HttpHeaders.IF_MODIFIED_SINCE),
date(headers, HttpHeaders.IF_UNMODIFIED_SINCE),
header(headers, HttpHeaders.IF_RANGE),
header(headers, HttpHeaders.RANGE),
headOnly);
}
private static Optional<String> header(HttpHeaders headers, String name) {
String value = headers.getFirst(name);
return value == null || value.isBlank() ? Optional.empty() : Optional.of(value);
}
/** A malformed HTTP-date is ignored rather than rejected, as RFC 9110 requires. */
private static Optional<Instant> date(HttpHeaders headers, String name) {
Optional<String> raw = header(headers, name);
if (raw.isEmpty()) {
return Optional.empty();
}
try {
return Optional.of(
ZonedDateTime.parse(raw.get(), DateTimeFormatter.RFC_1123_DATE_TIME).toInstant());
} catch (DateTimeParseException malformed) {
return Optional.empty();
}
}
private static RequestContext contextOf(ServerRequest request) {
return request
.attribute(ReactiveFileserverAttributes.REQUEST_CONTEXT)
.filter(RequestContext.class::isInstance)
.map(RequestContext.class::cast)
.orElseThrow(
() -> new IllegalStateException("fileserver request context attribute is missing"));
}
}
@@ -0,0 +1,165 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.BatchUploadItemResult;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.BatchUploadResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.RawUploadRequestMapper;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.UploadIntent;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
import dev.caskeleton.application.fileserver.upload.FileView;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
import java.net.URI;
import java.time.Clock;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.OptionalLong;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.PartEvent;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Reactive counterpart of the MVC upload endpoints.
*
* <p>It answers the same statuses and headers as the servlet path because both build their response
* from the same {@link FileView}. The body is never joined into a single buffer; each request is
* consumed as a bounded stream so a large upload does not scale with heap.
*/
public final class FileUploadHandler {
private final ReactiveUploadApplicationService uploadService;
private final PartEventUploadReader partReader;
private final FileserverWebProperties properties;
private final Clock clock;
public FileUploadHandler(
ReactiveUploadApplicationService uploadService,
PartEventUploadReader partReader,
FileserverWebProperties properties,
Clock clock) {
this.uploadService = uploadService;
this.partReader = partReader;
this.properties = properties;
this.clock = clock;
}
/** Raw streaming upload; the whole request body is the file. */
public Mono<ServerResponse> uploadRaw(ServerRequest request) {
UploadIntent intent = rawIntent(request);
RequestContext context = contextOf(request);
return uploadService
.upload(
createRequest(intent),
request.bodyToFlux(DataBuffer.class),
declaredLength(intent),
new FinalizeUploadRequest(intent.expectedSha256(), false),
context)
.flatMap(FileUploadHandler::created);
}
/** Multipart single upload; only the first file part is consumed. */
public Mono<ServerResponse> uploadMultipart(ServerRequest request) {
RequestContext context = contextOf(request);
return partReader
.forEachPart(
request.bodyToFlux(PartEvent.class),
(intent, content) -> uploadOne(intent, content, context))
.next()
.flatMap(FileUploadHandler::created);
}
/** Bounded multi-file upload; each part answers independently and none rolls back a sibling. */
public Mono<ServerResponse> uploadBatch(ServerRequest request) {
RequestContext context = contextOf(request);
return partReader
.forEachPart(
request.bodyToFlux(PartEvent.class),
(intent, content) ->
uploadOne(intent, content, context)
.map(view -> BatchUploadItemResult.accepted(intent.originalFilename(), view))
.onErrorResume(
FileserverException.class,
failure ->
Mono.just(
BatchUploadItemResult.rejected(
intent.originalFilename(), failure.code()))))
.collectList()
.flatMap(
results ->
ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new BatchUploadResponse(List.copyOf(results))));
}
private Mono<FileView> uploadOne(
UploadIntent intent, Flux<DataBuffer> content, RequestContext context) {
return uploadService.upload(
createRequest(intent),
content,
declaredLength(intent),
new FinalizeUploadRequest(intent.expectedSha256(), false),
context);
}
private CreateUploadRequest createRequest(UploadIntent intent) {
return new CreateUploadRequest(
properties.defaultNamespace(),
intent.originalFilename(),
intent.claimedMediaType(),
intent.declaredLength(),
intent.expectedSha256(),
UploadProtocol.RAW,
clock.instant().plus(properties.uploadTtl()));
}
/** READY is a finished object; anything still under verification is accepted, not created. */
private static Mono<ServerResponse> created(FileView view) {
HttpStatus status = view.state() == FileState.READY ? HttpStatus.CREATED : HttpStatus.ACCEPTED;
return ServerResponse.status(status)
.location(URI.create("/v1/files/" + view.fileId().canonicalText()))
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(UploadedFileResponse.from(view));
}
private static UploadIntent rawIntent(ServerRequest request) {
HttpHeaders headers = request.headers().asHttpHeaders();
String filename = headers.getFirst(RawUploadRequestMapper.FILENAME_HEADER);
String digest = headers.getFirst(RawUploadRequestMapper.DIGEST_HEADER);
long declared = headers.getContentLength();
return new UploadIntent(
filename == null || filename.isBlank() ? "upload.bin" : filename,
Optional.ofNullable(headers.getContentType()).map(Object::toString),
declared < 0 ? OptionalLong.empty() : OptionalLong.of(declared),
Optional.ofNullable(digest).map(value -> value.trim().toLowerCase(Locale.ROOT)));
}
/**
* Context for one reactive request.
*
* <p>The reactive stack has no thread-bound security context, so the attribute the router filter
* publishes is the only correct source here.
*/
private static RequestContext contextOf(ServerRequest request) {
return request
.attribute(ReactiveFileserverAttributes.REQUEST_CONTEXT)
.filter(RequestContext.class::isInstance)
.map(RequestContext.class::cast)
.orElseThrow(
() -> new IllegalStateException("fileserver request context attribute is missing"));
}
private static long declaredLength(UploadIntent intent) {
return intent.declaredLength().isPresent() ? intent.declaredLength().getAsLong() : -1;
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
/**
* The only place reactive Fileserver work is allowed to block.
*
* <p>The local content store is a blocking filesystem client. Running it on a Reactor Netty event
* loop would stall every other connection the loop owns, so all of it is offloaded here — a bounded
* pool with a bounded queue, which turns a filesystem stall into backpressure instead of an
* unbounded thread or task pile-up.
*/
public final class FileserverIoScheduler implements AutoCloseable {
private static final int TTL_SECONDS = 60;
private final Scheduler scheduler;
public FileserverIoScheduler(int workers, int queueCapacity) {
if (workers < 1 || queueCapacity < 1) {
throw new IllegalArgumentException("workers and queueCapacity must be positive");
}
this.scheduler =
Schedulers.newBoundedElastic(workers, queueCapacity, "fileserver-io", TTL_SECONDS, false);
}
public Scheduler scheduler() {
return scheduler;
}
@Override
public void close() {
scheduler.dispose();
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
import dev.caskeleton.adapter.inbound.web.fileserver.config.TransferExecutorProperties;
import dev.caskeleton.adapter.inbound.web.fileserver.problem.FileserverProblemFactory;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.download.DownloadApplicationService;
import dev.caskeleton.application.fileserver.upload.SingleShotUploadService;
import java.time.Clock;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Registers the reactive transport so it exists at runtime, not only in the source tree.
*
* <p>The router, the handlers and the part reader were written and tested, and nothing ever built
* them: no bean, no route, no dispatcher entry. A deployment that switched the platform on got the
* servlet transport and a set of classes that were never instantiated, while the support matrix
* advertised WebFlux as a supported profile.
*
* <p>The wiring lives in this module rather than in the composition root because {@code
* spring-webflux} is an {@code implementation} dependency here and deliberately invisible to {@code
* app-bootstrap} — the root cannot name {@link RouterFunction} at all, which is the mechanical
* reason the wiring was never written in the first place.
*
* <p>It stays inert in the shipped composition. This module also puts {@code DispatcherServlet} on
* the classpath, so Boot's application-type deduction resolves SERVLET and the condition below is
* false; a fork that removes the servlet stack and adds a reactive server gets working routes
* without touching this class. That is the honest support level, and the matrix says {@code
* Experimental} for exactly this reason.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class FileserverReactiveConfiguration {
/**
* Blocking storage work never runs on the event loop.
*
* <p>Bounded by the same transfer settings the servlet pool uses: an unbounded elastic scheduler
* would trade an event-loop stall for an unbounded thread count, which is the worse of the two.
*/
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public FileserverIoScheduler fileserverIoScheduler(TransferExecutorProperties properties) {
return new FileserverIoScheduler(properties.maxSize(), properties.queueCapacity());
}
@Bean
@ConditionalOnMissingBean
public PartEventUploadReader fileserverPartEventUploadReader(FileserverWebProperties properties) {
return new PartEventUploadReader(properties.maxBatchParts());
}
@Bean
@ConditionalOnMissingBean
public ReactiveUploadApplicationService fileserverReactiveUploadService(
SingleShotUploadService uploadService, FileserverIoScheduler ioScheduler) {
return new ReactiveUploadApplicationService(uploadService, ioScheduler);
}
@Bean
@ConditionalOnMissingBean
public ReactiveDownloadResponseWriter fileserverReactiveDownloadResponseWriter() {
return new ReactiveDownloadResponseWriter();
}
@Bean
@ConditionalOnMissingBean
public FileUploadHandler fileserverReactiveUploadHandler(
ReactiveUploadApplicationService uploadService,
PartEventUploadReader partReader,
FileserverWebProperties properties,
Clock clock) {
return new FileUploadHandler(uploadService, partReader, properties, clock);
}
@Bean
@ConditionalOnMissingBean
public FileDownloadHandler fileserverReactiveDownloadHandler(
DownloadApplicationService downloadService,
ReactiveDownloadResponseWriter responseWriter,
FileserverIoScheduler ioScheduler) {
return new FileDownloadHandler(downloadService, responseWriter, ioScheduler);
}
/**
* The routes themselves.
*
* <p>A {@link RouterFunction} bean is how WebFlux discovers routes; the factory existed but
* nothing ever called {@code routes()} on it.
*/
@Bean
public RouterFunction<ServerResponse> fileserverRoutes(
FileUploadHandler uploadHandler,
FileDownloadHandler downloadHandler,
FileserverProblemFactory problemFactory,
FileserverRequestContextFactory contextFactory) {
return new FileserverRouterFactory(
uploadHandler, downloadHandler, problemFactory, contextFactory::current)
.routes();
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import dev.caskeleton.adapter.inbound.web.fileserver.problem.FileserverProblem;
import dev.caskeleton.adapter.inbound.web.fileserver.problem.FileserverProblemFactory;
import dev.caskeleton.adapter.inbound.web.fileserver.problem.FileserverProblemHeaders;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import java.util.function.Supplier;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
/**
* Wires the reactive Fileserver routes and their failure translation.
*
* <p>The colon-verb paths are declared literally rather than nested under a prefix: {@code
* /v1/files:raw} is one path segment, and a nested route would silently turn it into {@code
* /v1/files/:raw}.
*
* <p>The failure filter lives here rather than in a global handler so the reactive routes translate
* Fileserver failures to exactly the same statuses and problem documents as the servlet advice.
*/
public final class FileserverRouterFactory {
private final FileUploadHandler uploadHandler;
private final FileDownloadHandler downloadHandler;
private final FileserverProblemFactory problemFactory;
private final Supplier<RequestContext> contextSupplier;
public FileserverRouterFactory(
FileUploadHandler uploadHandler,
FileDownloadHandler downloadHandler,
FileserverProblemFactory problemFactory,
Supplier<RequestContext> contextSupplier) {
this.uploadHandler = uploadHandler;
this.downloadHandler = downloadHandler;
this.problemFactory = problemFactory;
this.contextSupplier = contextSupplier;
}
public RouterFunction<ServerResponse> routes() {
return RouterFunctions.route()
.POST("/v1/files:raw", uploadHandler::uploadRaw)
.POST("/v1/files:batch", uploadHandler::uploadBatch)
.POST("/v1/files", uploadHandler::uploadMultipart)
.GET("/v1/files/{fileId}/content", downloadHandler::download)
.route(RequestPredicates.HEAD("/v1/files/{fileId}/content"), downloadHandler::download)
.GET("/v1/files/{fileId}", downloadHandler::describe)
.before(
request -> {
request
.attributes()
.put(ReactiveFileserverAttributes.REQUEST_CONTEXT, contextSupplier.get());
return request;
})
.onError(FileserverException.class, this::toProblem)
.build();
}
private Mono<ServerResponse> toProblem(
Throwable throwable, org.springframework.web.reactive.function.server.ServerRequest request) {
FileserverException failure = (FileserverException) throwable;
FileserverProblem problem =
problemFactory.create(failure.context(), request.requestPath().value());
ServerResponse.BodyBuilder builder =
ServerResponse.status(problem.status()).contentType(MediaType.APPLICATION_PROBLEM_JSON);
// Same table the servlet advice uses. Retry-After was missing here, so an identical failure
// told a servlet client to come back in a second and a reactive client nothing at all.
FileserverProblemHeaders.of(failure).forEach(builder::header);
return builder.bodyValue(problem);
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.UploadIntent;
import dev.caskeleton.application.fileserver.api.error.FileTooLargeException;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiFunction;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.codec.multipart.FilePartEvent;
import org.springframework.http.codec.multipart.PartEvent;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Reads a multipart body as a sequence of part events, one part at a time.
*
* <p>Sequential windowing is the contract, not an implementation detail: part events arrive on one
* stream, so consuming two windows concurrently would interleave the bytes of different files. The
* part-count ceiling is enforced as windows arrive, so an oversized batch is rejected before the
* remaining parts are read rather than after.
*
* <p>A non-file part is drained and released rather than ignored — an ignored window would leave
* its pooled buffers unreferenced and unreleased.
*/
public final class PartEventUploadReader {
private static final String FALLBACK_FILENAME = "upload.bin";
private final int maxParts;
public PartEventUploadReader(int maxParts) {
if (maxParts < 1) {
throw new IllegalArgumentException("maxParts must be positive");
}
this.maxParts = maxParts;
}
/**
* Applies {@code handler} to each file part, in order.
*
* <p>{@code concatMap} rather than {@code flatMap} is required: it subscribes to the next window
* only after the previous one terminates, which is what keeps parts from interleaving.
*/
public <T> Flux<T> forEachPart(
Flux<PartEvent> events, BiFunction<UploadIntent, Flux<DataBuffer>, Mono<T>> handler) {
AtomicInteger seen = new AtomicInteger();
return events
.windowUntil(PartEvent::isLast)
.concatMap(
window ->
window.switchOnFirst((signal, rest) -> onePart(signal.get(), rest, seen, handler)));
}
private <T> Flux<T> onePart(
PartEvent first,
Flux<PartEvent> rest,
AtomicInteger seen,
BiFunction<UploadIntent, Flux<DataBuffer>, Mono<T>> handler) {
if (first == null) {
return Flux.empty();
}
if (seen.incrementAndGet() > maxParts) {
return drain(rest).thenMany(Flux.error(tooManyParts()));
}
if (!(first instanceof FilePartEvent filePart)) {
return drain(rest).thenMany(Flux.empty());
}
return handler.apply(intentOf(filePart), rest.map(PartEvent::content)).flux();
}
private static Mono<Void> drain(Flux<PartEvent> events) {
return events.doOnNext(event -> DataBufferUtils.release(event.content())).then();
}
private static UploadIntent intentOf(FilePartEvent part) {
String filename = part.filename();
return new UploadIntent(
filename == null || filename.isBlank() ? FALLBACK_FILENAME : filename,
Optional.ofNullable(part.headers().getContentType()).map(Object::toString),
OptionalLong.empty(),
Optional.empty());
}
private static FileTooLargeException tooManyParts() {
return new FileTooLargeException(
"batch exceeds the configured maximum part count",
FileserverFailureContext.of(FileserverErrorCode.FILE_TOO_LARGE, false));
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
/**
* Renders a download decision into reactive response headers.
*
* <p>It is deliberately a mirror of the servlet writer, driven by the same descriptor, so MVC and
* WebFlux answer byte-identical headers for the same request. A parity test compares them directly.
*/
public final class ReactiveDownloadResponseWriter {
/** Header that stops a browser re-sniffing a declared media type. */
public static final String CONTENT_TYPE_OPTIONS = "X-Content-Type-Options";
private static final DateTimeFormatter HTTP_DATE =
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
/**
* Builds the header set for {@code descriptor}; {@code 304} carries no representation metadata.
*/
public HttpHeaders headersFor(DownloadDescriptor descriptor) {
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.ETAG, descriptor.representation().strongEtag());
headers.set(
HttpHeaders.LAST_MODIFIED,
HTTP_DATE.format(
ZonedDateTime.ofInstant(descriptor.representation().lastModified(), ZoneOffset.UTC)));
headers.set(HttpHeaders.ACCEPT_RANGES, "bytes");
headers.set(HttpHeaders.CACHE_CONTROL, descriptor.cacheControl());
// Same reason as the servlet writer: uploaded content is never allowed to describe itself.
headers.set(CONTENT_TYPE_OPTIONS, "nosniff");
if (descriptor.status() == HttpStatus.NOT_MODIFIED.value()) {
return headers;
}
headers.set(HttpHeaders.CONTENT_TYPE, descriptor.representation().mediaType());
headers.set(HttpHeaders.CONTENT_DISPOSITION, descriptor.contentDisposition());
headers.set(HttpHeaders.CONTENT_LENGTH, String.valueOf(payloadLength(descriptor)));
if (descriptor.isPartial()) {
ByteRange range = descriptor.singleRange();
headers.set(
HttpHeaders.CONTENT_RANGE,
"bytes "
+ range.startInclusive()
+ "-"
+ range.endInclusive()
+ "/"
+ descriptor.representation().length());
}
return headers;
}
/** Range the body should cover; a full representation is expressed as one whole-object range. */
public ByteRange payloadRange(DownloadDescriptor descriptor) {
return descriptor.isPartial()
? descriptor.singleRange()
: ByteRange.entire(descriptor.representation().length());
}
private static long payloadLength(DownloadDescriptor descriptor) {
return descriptor.isPartial()
? descriptor.singleRange().length()
: descriptor.representation().length();
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
/**
* Names of the request attributes the reactive Fileserver routes rely on.
*
* <p>The reactive stack carries no thread-bound security context, so the caller identity has to
* travel with the exchange rather than through a holder.
*/
public final class ReactiveFileserverAttributes {
/**
* Attribute holding the resolved {@code RequestContext}.
*
* <p>Published by the router filter, consumed by the handlers.
*/
public static final String REQUEST_CONTEXT = "dev.caskeleton.fileserver.reactive.requestContext";
private ReactiveFileserverAttributes() {}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.adapter.inbound.web.fileserver.reactive;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
import dev.caskeleton.application.fileserver.upload.FileView;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
import dev.caskeleton.application.fileserver.upload.SingleShotUploadService;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.ReadableByteChannel;
import org.springframework.core.io.buffer.DataBuffer;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Runs the blocking single-shot upload against a reactive body.
*
* <p>The whole body is never joined. Instead the reactive stream is turned into a bounded blocking
* channel and the upload is scheduled on the dedicated I/O pool, so a multi-gigabyte upload costs a
* fixed number of in-flight buffers and never touches an event-loop thread.
*/
public final class ReactiveUploadApplicationService {
/** In-flight buffer ceiling; also the initial reactive demand. */
static final int PREFETCH_BUFFERS = 8;
private final SingleShotUploadService uploadService;
private final FileserverIoScheduler ioScheduler;
public ReactiveUploadApplicationService(
SingleShotUploadService uploadService, FileserverIoScheduler ioScheduler) {
this.uploadService = uploadService;
this.ioScheduler = ioScheduler;
}
public Mono<FileView> upload(
CreateUploadRequest request,
Flux<DataBuffer> body,
long contentLength,
FinalizeUploadRequest finalizeRequest,
RequestContext context) {
return Mono.fromCallable(() -> transfer(request, body, contentLength, finalizeRequest, context))
.subscribeOn(ioScheduler.scheduler());
}
private FileView transfer(
CreateUploadRequest request,
Flux<DataBuffer> body,
long contentLength,
FinalizeUploadRequest finalizeRequest,
RequestContext context) {
try (ReadableByteChannel channel = DataBufferByteChannel.subscribeTo(body, PREFETCH_BUFFERS)) {
return uploadService.upload(request, channel, contentLength, finalizeRequest, context);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.inbound.web.fileserver.security;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.application.fileserver.api.security.FileAccessSubject;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import java.util.LinkedHashSet;
import java.util.Set;
import org.slf4j.MDC;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* Builds the framework-free {@link RequestContext} the Fileserver expects.
*
* <p>This is the single place a Spring Security type is translated into a {@link
* FileAccessSubject}; no Fileserver class below it ever sees an {@code Authentication}. An
* unauthenticated caller becomes the anonymous subject rather than a null one, so the injected
* access policy — not this adapter — decides whether that is allowed.
*/
public final class FileserverRequestContextFactory {
private static final String UNKNOWN_TRACE = "untraced";
private final String instanceId;
public FileserverRequestContextFactory(String instanceId) {
if (instanceId == null || instanceId.isBlank()) {
throw new IllegalArgumentException("instanceId must be non-blank");
}
this.instanceId = instanceId;
}
/** Context for the request currently bound to this thread. */
public RequestContext current() {
return new RequestContext(subject(), traceId(), instanceId);
}
private static FileAccessSubject subject() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return FileAccessSubject.anonymous();
}
Object principal = authentication.getPrincipal();
if (principal instanceof AuthenticatedPrincipal authenticated) {
return FileAccessSubject.of(authenticated.idpUserId(), authenticated.roles());
}
return FileAccessSubject.of(authentication.getName(), authorities(authentication));
}
private static Set<String> authorities(Authentication authentication) {
Set<String> roles = new LinkedHashSet<>();
for (GrantedAuthority authority : authentication.getAuthorities()) {
roles.add(authority.getAuthority());
}
return roles;
}
/**
* Correlation id for the current request.
*
* <p>The MDC value is set by the request-logging filter. A blank value degrades to a constant
* rather than a generated one: identifier generation at this layer is a different concern.
*/
private static String traceId() {
String traceId = MDC.get(MdcKeys.TRACE_ID);
return traceId == null || traceId.isBlank() ? UNKNOWN_TRACE : traceId;
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.inbound.web.fileserver.tus;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.error.IntegrityMismatchException;
import dev.caskeleton.application.fileserver.api.error.MalformedRequestException;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Set;
/**
* Verifies a tus {@code Upload-Checksum} against the digest the server computed.
*
* <p>The client value is only ever compared with the server's own digest; it never replaces it. A
* checksum the server did not compute proves nothing, and accepting one would let a client declare
* corrupt bytes to be intact.
*/
public final class TusChecksumVerifier {
private static final Set<String> SUPPORTED = Set.of("sha256");
/**
* Compares a checksum header with the server digest.
*
* @param header the raw {@code algorithm base64value} pair
* @param serverDigestHex the lowercase hex digest the server computed over the same bytes
*/
public void verify(String header, String serverDigestHex) {
String[] parts = header.trim().split(" ", 2);
if (parts.length != 2) {
throw MalformedRequestException.of("Upload-Checksum must be an algorithm and a base64 value");
}
String algorithm = parts[0].toLowerCase(Locale.ROOT);
if (!SUPPORTED.contains(algorithm)) {
throw MalformedRequestException.of("unsupported checksum algorithm");
}
byte[] claimed;
try {
claimed = Base64.getDecoder().decode(parts[1].trim());
} catch (IllegalArgumentException malformed) {
throw new MalformedRequestException(
"Upload-Checksum value is not valid base64",
malformed,
FileserverFailureContext.of(FileserverErrorCode.BAD_REQUEST, false));
}
if (!HexFormat.of().formatHex(claimed).equals(serverDigestHex)) {
throw new IntegrityMismatchException(
"client checksum does not match the server-computed digest",
FileserverFailureContext.of(FileserverErrorCode.INTEGRITY_MISMATCH, false));
}
}
}
@@ -0,0 +1,207 @@
package dev.caskeleton.adapter.inbound.web.fileserver.tus;
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import dev.caskeleton.application.fileserver.upload.AppendUploadResult;
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadService;
import dev.caskeleton.application.fileserver.upload.UploadApplicationService;
import dev.caskeleton.application.fileserver.upload.UploadSessionView;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.channels.Channels;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
/**
* tus 1.0 Stable resumable uploads.
*
* <p>This is a protocol mapping, not a second upload implementation: creation, append, and
* finalization all go through the same application services the plain HTTP endpoints use, so the
* offset, lease, and digest guarantees are identical no matter which protocol a client speaks.
*
* <p>Every protocol request validates {@code Tus-Resumable} first. An offset mismatch answers
* {@code 409} without touching the body, which is what makes a mismatched resume safe to retry.
*/
@RestController
@ConditionalOnProperty(
prefix = "app.fileserver-platform",
name = {"enabled", "tus.enabled"},
havingValue = "true")
public class TusController {
private static final DateTimeFormatter HTTP_DATE =
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
private final UploadApplicationService uploadService;
private final FinalizeUploadService finalizeService;
private final TusRequestParser parser;
private final TusChecksumVerifier checksumVerifier;
private final FileserverRequestContextFactory contextFactory;
private final BlockingTransferExecutor transferExecutor;
private final FileserverWebProperties webProperties;
private final TusProperties tusProperties;
private final Clock clock;
public TusController(
UploadApplicationService uploadService,
FinalizeUploadService finalizeService,
TusRequestParser parser,
TusChecksumVerifier checksumVerifier,
FileserverRequestContextFactory contextFactory,
BlockingTransferExecutor transferExecutor,
FileserverWebProperties webProperties,
TusProperties tusProperties,
Clock clock) {
this.uploadService = uploadService;
this.finalizeService = finalizeService;
this.parser = parser;
this.checksumVerifier = checksumVerifier;
this.contextFactory = contextFactory;
this.transferExecutor = transferExecutor;
this.webProperties = webProperties;
this.tusProperties = tusProperties;
this.clock = clock;
}
/** Capability discovery; the only tus request that does not require a version header. */
@RequestMapping(path = "/v1/uploads", method = RequestMethod.OPTIONS)
public ResponseEntity<Void> options() {
return ResponseEntity.noContent()
.header(TusHeaders.RESUMABLE, tusProperties.version())
.header(TusHeaders.VERSION, tusProperties.version())
.header(TusHeaders.EXTENSION, tusProperties.extensionHeader())
.header(TusHeaders.MAX_SIZE, String.valueOf(tusProperties.maxSize()))
.build();
}
@PostMapping("/v1/uploads")
public ResponseEntity<Void> create(HttpServletRequest request) {
parser.requireProtocolVersion(request);
RequestContext context = contextFactory.current();
Instant expiresAt = clock.instant().plus(tusProperties.uploadTtl());
UploadSessionView created =
uploadService.create(
new CreateUploadRequest(
webProperties.defaultNamespace(),
parser.filename(request).orElse("upload.bin"),
Optional.ofNullable(parser.metadata(request).get("filetype")),
parser.declaredLength(request),
Optional.empty(),
UploadProtocol.TUS_1_0,
expiresAt),
context);
return ResponseEntity.created(URI.create("/v1/uploads/" + created.uploadId().canonicalText()))
.header(TusHeaders.RESUMABLE, tusProperties.version())
.header(TusHeaders.UPLOAD_OFFSET, "0")
.header(TusHeaders.UPLOAD_EXPIRES, httpDate(created.expiresAt()))
.build();
}
@RequestMapping(path = "/v1/uploads/{uploadId}", method = RequestMethod.HEAD)
public ResponseEntity<Void> status(
@PathVariable("uploadId") String uploadId, HttpServletRequest request) {
parser.requireProtocolVersion(request);
UploadSessionView session =
uploadService.status(UploadId.parse(uploadId), contextFactory.current());
ResponseEntity.HeadersBuilder<?> response =
ResponseEntity.noContent()
.header(TusHeaders.RESUMABLE, tusProperties.version())
.header(TusHeaders.UPLOAD_OFFSET, String.valueOf(session.committedOffset()))
.header(TusHeaders.UPLOAD_EXPIRES, httpDate(session.expiresAt()))
// A resumable resource must never be served from a cache: a stale offset would make the
// client resume from the wrong position.
.header(HttpHeaders.CACHE_CONTROL, "no-store");
if (session.expectedLength().isPresent()) {
response =
response.header(
TusHeaders.UPLOAD_LENGTH, String.valueOf(session.expectedLength().getAsLong()));
} else {
response = response.header(TusHeaders.UPLOAD_DEFER_LENGTH, "1");
}
return response.build();
}
// The channel wraps the servlet request body. Closing it would close the container's input
// stream, which the container owns and reuses for keep-alive; the upload must read the body
// and leave the stream alone.
@SuppressWarnings("resource")
@PatchMapping("/v1/uploads/{uploadId}")
public ResponseEntity<Void> append(
@PathVariable("uploadId") String uploadId, HttpServletRequest request) throws IOException {
parser.requireProtocolVersion(request);
parser.requireOffsetContentType(request);
UploadId id = UploadId.parse(uploadId);
long expectedOffset = parser.requiredOffset(request);
RequestContext context = contextFactory.current();
InputStream body = request.getInputStream();
long declared = request.getContentLengthLong();
AppendUploadResult appended =
transferExecutor.call(
() ->
uploadService.append(
id, expectedOffset, Channels.newChannel(body), declared, context));
parser
.checksum(request)
.ifPresent(header -> checksumVerifier.verify(header, appended.sha256Snapshot()));
UploadSessionView session = uploadService.status(id, context);
if (isComplete(session, appended)) {
finalizeService.finalizeUpload(id, FinalizeUploadRequest.synchronousWithoutDigest(), context);
}
return ResponseEntity.noContent()
.header(TusHeaders.RESUMABLE, tusProperties.version())
.header(TusHeaders.UPLOAD_OFFSET, String.valueOf(appended.committedOffset()))
.header(TusHeaders.UPLOAD_EXPIRES, httpDate(session.expiresAt()))
.build();
}
@DeleteMapping("/v1/uploads/{uploadId}")
public ResponseEntity<Void> terminate(
@PathVariable("uploadId") String uploadId, HttpServletRequest request) {
parser.requireProtocolVersion(request);
uploadService.cancel(UploadId.parse(uploadId), contextFactory.current());
return ResponseEntity.noContent().header(TusHeaders.RESUMABLE, tusProperties.version()).build();
}
/**
* Decides whether the upload just reached its declared length.
*
* <p>An upload with a deferred length is never auto-finalized here: only the client knows when it
* is done, and finalizing early would publish a truncated object.
*/
private static boolean isComplete(UploadSessionView session, AppendUploadResult appended) {
return session.expectedLength().isPresent()
&& session.expectedLength().getAsLong() == appended.committedOffset();
}
private static String httpDate(Instant instant) {
return HTTP_DATE.format(ZonedDateTime.ofInstant(instant, ZoneOffset.UTC));
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.web.fileserver.tus;
/** The tus 1.0 header vocabulary, named once so no handler re-spells one. */
public final class TusHeaders {
public static final String RESUMABLE = "Tus-Resumable";
public static final String VERSION = "Tus-Version";
public static final String EXTENSION = "Tus-Extension";
public static final String MAX_SIZE = "Tus-Max-Size";
public static final String UPLOAD_OFFSET = "Upload-Offset";
public static final String UPLOAD_LENGTH = "Upload-Length";
public static final String UPLOAD_DEFER_LENGTH = "Upload-Defer-Length";
public static final String UPLOAD_METADATA = "Upload-Metadata";
public static final String UPLOAD_EXPIRES = "Upload-Expires";
public static final String UPLOAD_CHECKSUM = "Upload-Checksum";
/** The only content type a tus PATCH may carry. */
public static final String OFFSET_OCTET_STREAM = "application/offset+octet-stream";
private TusHeaders() {}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.web.fileserver.tus;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/**
* tus 1.0 profile.
*
* <p>The advertised extension list is what a client negotiates against, so it must describe what
* this server actually implements. Advertising an extension that is not wired is worse than not
* advertising it: the client will use it and fail mid-upload.
*/
public record TusProperties(
String version, List<String> extensions, long maxSize, Duration uploadTtl) {
public static final String RESUMABLE_VERSION = "1.0.0";
public TusProperties {
Objects.requireNonNull(version, "version");
Objects.requireNonNull(extensions, "extensions");
Objects.requireNonNull(uploadTtl, "uploadTtl");
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize must be positive");
}
if (uploadTtl.isNegative() || uploadTtl.isZero()) {
throw new IllegalArgumentException("uploadTtl must be positive");
}
extensions = List.copyOf(extensions);
}
/** Design standard profile: creation, expiration, checksum, and termination. */
public static TusProperties standard() {
return new TusProperties(
RESUMABLE_VERSION,
List.of("creation", "expiration", "checksum", "termination"),
100L * 1024 * 1024,
Duration.ofHours(1));
}
public String extensionHeader() {
return String.join(",", extensions);
}
}
@@ -0,0 +1,140 @@
package dev.caskeleton.adapter.inbound.web.fileserver.tus;
import dev.caskeleton.application.fileserver.api.error.FileTooLargeException;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.error.MalformedRequestException;
import dev.caskeleton.application.fileserver.api.error.UnsupportedMediaTypeException;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Reads and validates the tus protocol headers of one request.
*
* <p>Version negotiation is checked first and unconditionally: a client that omits {@code
* Tus-Resumable} is not speaking tus, and answering it as though it were is how a plain POST gets
* silently treated as an upload creation.
*
* <p>{@code Upload-Metadata} is decoded but never trusted — the filename it carries is display data
* that the application layer sanitizes, exactly like a multipart filename.
*/
public final class TusRequestParser {
private static final String FILENAME_KEY = "filename";
private final TusProperties properties;
public TusRequestParser(TusProperties properties) {
this.properties = properties;
}
/** Rejects a request that is not this protocol version. */
public void requireProtocolVersion(HttpServletRequest request) {
String resumable = request.getHeader(TusHeaders.RESUMABLE);
if (!properties.version().equals(resumable)) {
throw MalformedRequestException.of("unsupported or missing tus protocol version");
}
}
/** Rejects a PATCH body that is not the tus offset media type. */
public void requireOffsetContentType(HttpServletRequest request) {
String contentType = request.getContentType();
if (contentType == null || !contentType.startsWith(TusHeaders.OFFSET_OCTET_STREAM)) {
throw new UnsupportedMediaTypeException(
"tus PATCH requires the offset octet-stream media type",
FileserverFailureContext.of(FileserverErrorCode.UNSUPPORTED_MEDIA_TYPE, false));
}
}
/**
* Reads the declared final length.
*
* <p>{@code Upload-Defer-Length: 1} means the client will declare it later, which is legal and
* distinct from a length of zero.
*/
public OptionalLong declaredLength(HttpServletRequest request) {
if ("1".equals(request.getHeader(TusHeaders.UPLOAD_DEFER_LENGTH))) {
return OptionalLong.empty();
}
String header = request.getHeader(TusHeaders.UPLOAD_LENGTH);
if (header == null || header.isBlank()) {
throw MalformedRequestException.of(
"tus creation requires Upload-Length or Upload-Defer-Length");
}
long length = parseNonNegative(header);
if (length > properties.maxSize()) {
throw new FileTooLargeException(
"declared upload length exceeds the advertised maximum",
FileserverFailureContext.of(FileserverErrorCode.FILE_TOO_LARGE, false));
}
return OptionalLong.of(length);
}
public long requiredOffset(HttpServletRequest request) {
String header = request.getHeader(TusHeaders.UPLOAD_OFFSET);
if (header == null || header.isBlank()) {
throw MalformedRequestException.of("tus PATCH requires Upload-Offset");
}
return parseNonNegative(header);
}
/** Display filename from {@code Upload-Metadata}, still untrusted at this point. */
public Optional<String> filename(HttpServletRequest request) {
return Optional.ofNullable(metadata(request).get(FILENAME_KEY));
}
/**
* Decodes the base64 pairs of {@code Upload-Metadata}.
*
* <p>A pair whose value does not decode is dropped rather than failing the request: metadata is
* advisory, and rejecting an upload over a cosmetic field would be worse than ignoring it.
*/
public Map<String, String> metadata(HttpServletRequest request) {
String header = request.getHeader(TusHeaders.UPLOAD_METADATA);
Map<String, String> decoded = new LinkedHashMap<>();
if (header == null || header.isBlank()) {
return decoded;
}
for (String pair : header.split(",", -1)) {
String[] parts = pair.trim().split(" ", 2);
if (parts.length != 2 || parts[0].isBlank()) {
continue;
}
try {
decoded.put(
parts[0].toLowerCase(Locale.ROOT),
new String(Base64.getDecoder().decode(parts[1]), StandardCharsets.UTF_8));
} catch (IllegalArgumentException undecodable) {
// Advisory metadata; a malformed pair is skipped, never fatal.
}
}
return decoded;
}
/** {@code Upload-Checksum} as {@code algorithm base64value}, when present. */
public Optional<String> checksum(HttpServletRequest request) {
String header = request.getHeader(TusHeaders.UPLOAD_CHECKSUM);
return header == null || header.isBlank() ? Optional.empty() : Optional.of(header.trim());
}
private static long parseNonNegative(String raw) {
try {
long value = Long.parseLong(raw.trim());
if (value < 0) {
throw new NumberFormatException("negative");
}
return value;
} catch (NumberFormatException malformed) {
throw new MalformedRequestException(
"tus header is not a non-negative integer",
malformed,
FileserverFailureContext.of(FileserverErrorCode.BAD_REQUEST, false));
}
}
}
@@ -0,0 +1,233 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import jakarta.servlet.Filter;
import java.util.List;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.ObjectMapper;
/** Exercises production CORS settings through the real Spring Security filter chain. */
@Tag("security-boundary")
class CorsSecurityFilterIntegrationTest {
private static final String ALLOWED_ORIGIN = "https://console.example.test";
private final WebApplicationContextRunner runner =
new WebApplicationContextRunner()
.withUserConfiguration(PropertiesConfig.class, SecurityConfig.class)
.withBean(JwtToAuthenticatedPrincipalConverter.class)
.withBean(ObjectMapper.class, ObjectMapper::new)
.withBean(
JwtDecoder.class,
() ->
token -> {
throw new AssertionError("CORS requests must not decode a bearer token");
});
@Test
void allowedCredentialedPreflightRunsBeforeAuthenticationWithExactPolicy() {
withCors(
true,
ALLOWED_ORIGIN,
true,
mvc -> {
MvcResult result =
mvc.perform(
options("/protected")
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "X-Request-ID"))
.andExpect(status().isOk())
.andReturn();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isEqualTo(ALLOWED_ORIGIN);
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS))
.isEqualTo("true");
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS))
.contains("POST");
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS))
.isEqualToIgnoringCase("X-Request-ID");
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE))
.isEqualTo("600");
assertBoundedVary(result, true);
});
}
@Test
void deniedOriginIs403WithoutOriginOrCredentialReflection() {
String deniedOrigin = "https://SECRET_DENIED_ORIGIN.example";
withCors(
true,
ALLOWED_ORIGIN,
true,
mvc -> {
MvcResult result =
mvc.perform(
options("/protected")
.header(HttpHeaders.ORIGIN, deniedOrigin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.andExpect(status().isForbidden())
.andReturn();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isNull();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS))
.isNull();
assertThat(result.getResponse().getContentAsString()).doesNotContain(deniedOrigin);
assertBoundedVary(result, true);
});
}
@Test
void disabledCorsEmitsNoCorsPolicyHeaders() {
withCors(
false,
ALLOWED_ORIGIN,
false,
mvc -> {
MvcResult result =
mvc.perform(get("/public").header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN))
.andExpect(status().isOk())
.andReturn();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isNull();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS))
.isNull();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS))
.isNull();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS))
.isNull();
});
}
@Test
void wildcardWithoutCredentialsReturnsWildcardAndNoCredentialHeader() {
withCors(
true,
"*",
false,
mvc -> {
MvcResult result =
mvc.perform(
options("/protected")
.header(HttpHeaders.ORIGIN, "https://arbitrary.example.test")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.andExpect(status().isOk())
.andReturn();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isEqualTo("*");
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS))
.isNull();
assertBoundedVary(result, true);
});
}
@Test
void allowedActualOriginUsesMatchingHeaderAndBoundedVary() {
withCors(
true,
ALLOWED_ORIGIN,
true,
mvc -> {
MvcResult result =
mvc.perform(get("/public").header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN))
.andExpect(status().isOk())
.andReturn();
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isEqualTo(ALLOWED_ORIGIN);
assertThat(result.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS))
.isEqualTo("true");
assertBoundedVary(result, false);
});
}
private void withCors(
boolean enabled, String origin, boolean credentials, ThrowingConsumer<MockMvc> assertion) {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=jwt",
"ca-skeleton.security.issuer-uri=https://issuer.example.test",
"ca-skeleton.security.audience=ca-skeleton-api",
"ca-skeleton.security.public-paths=/public",
"ca-skeleton.cors.enabled=" + enabled,
"ca-skeleton.cors.allowed-origins[0]=" + origin,
"ca-skeleton.cors.allowed-methods[0]=GET",
"ca-skeleton.cors.allowed-methods[1]=POST",
"ca-skeleton.cors.allowed-headers[0]=X-Request-ID",
"ca-skeleton.cors.allowed-headers[1]=Content-Type",
"ca-skeleton.cors.allow-credentials=" + credentials,
"ca-skeleton.cors.max-age-seconds=600")
.run(
context -> {
assertThat(context).hasNotFailed();
MockMvc mvc =
MockMvcBuilders.standaloneSetup(new ProbeController())
.addFilters(context.getBean("springSecurityFilterChain", Filter.class))
.build();
try {
assertion.accept(mvc);
} catch (Exception exception) {
throw new AssertionError("CORS security boundary assertion failed", exception);
}
});
}
private static void assertBoundedVary(MvcResult result, boolean preflight) {
List<String> vary = result.getResponse().getHeaders(HttpHeaders.VARY);
assertThat(vary).contains("Origin");
if (preflight) {
assertThat(vary).contains("Access-Control-Request-Method", "Access-Control-Request-Headers");
}
assertThat(vary)
.allMatch(
value ->
value.equals("Origin")
|| value.equals("Access-Control-Request-Method")
|| value.equals("Access-Control-Request-Headers"));
}
@FunctionalInterface
private interface ThrowingConsumer<T> {
void accept(T value) throws Exception;
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({SecuritySettings.class, CorsSettings.class})
static class PropertiesConfig {}
@RestController
static class ProbeController {
@GetMapping("/public")
String publicEndpoint() {
return "ok";
}
@PostMapping("/protected")
String protectedEndpoint() {
return "protected";
}
}
}
@@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Locale;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
@@ -56,7 +57,8 @@ class JwtDecoderConfigTest {
assertThat(result.getErrors())
.anyMatch(
e ->
e.getDescription() != null && e.getDescription().toLowerCase().contains("expired"));
e.getDescription() != null
&& e.getDescription().toLowerCase(Locale.ROOT).contains("expired"));
}
@Test
@@ -66,7 +68,9 @@ class JwtDecoderConfigTest {
assertThat(result.hasErrors()).isTrue();
assertThat(result.getErrors())
.anyMatch(
e -> e.getDescription() != null && e.getDescription().toLowerCase().contains("iss"));
e ->
e.getDescription() != null
&& e.getDescription().toLowerCase(Locale.ROOT).contains("iss"));
}
@Test
@@ -0,0 +1,455 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import jakarta.servlet.Filter;
import java.io.IOException;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.ObjectMapper;
/** Crosses bearer token, OIDC discovery/JWKS, validators, converter, filter chain, and envelope. */
@Tag("security-boundary")
class JwtJwksSecurityFilterIntegrationTest {
private static final String AUDIENCE = "ca-skeleton-api";
private static final String PRIMARY_KID = "primary-key";
private static final KeyPair PRIMARY_KEY = generateKeyPair();
private static final KeyPair ALTERNATE_KEY = generateKeyPair();
private final WebApplicationContextRunner runner =
new WebApplicationContextRunner()
.withUserConfiguration(
PropertiesConfig.class, SecurityConfig.class, JwtDecoderConfig.class)
.withBean(JwtToAuthenticatedPrincipalConverter.class)
.withBean(ObjectMapper.class, ObjectMapper::new);
@Test
void startupIsLazyAndValidSignedTokenReachesAuthenticatedPrincipal() throws Exception {
try (OidcServer issuer = OidcServer.available()) {
String token =
token(
PRIMARY_KEY, PRIMARY_KID, issuer.issuer(), AUDIENCE, Instant.now().plusSeconds(300));
withContext(
issuer,
mvc ->
mvc.perform(get("/protected").header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subject").value("user-1"))
.andExpect(jsonPath("$.roles[0]").value("operator")));
assertThat(issuer.discoveryRequests()).isEqualTo(1);
assertThat(issuer.jwksRequests()).isEqualTo(1);
}
}
@Test
void expiredTokenBeyondClockSkewUsesExactSafeEnvelope() throws Exception {
try (OidcServer issuer = OidcServer.available()) {
String sentinel = "SECRET_EXPIRED_TOKEN";
String token =
token(
PRIMARY_KEY, PRIMARY_KID, issuer.issuer(), AUDIENCE, Instant.now().minusSeconds(90));
assertUnauthorized(issuer, token, "AUTH_TOKEN_EXPIRED", false, null, sentinel);
}
}
@Test
void issuerMismatchUsesExactSafeEnvelope() throws Exception {
try (OidcServer issuer = OidcServer.available()) {
String sentinel = "SECRET_WRONG_ISSUER";
String token =
token(
PRIMARY_KEY,
PRIMARY_KID,
"https://" + sentinel + ".invalid/realm",
AUDIENCE,
Instant.now().plusSeconds(300));
assertUnauthorized(issuer, token, "AUTH_ISSUER_MISMATCH", false, null, sentinel);
}
}
@Test
void audienceMismatchUsesExactSafeEnvelope() throws Exception {
try (OidcServer issuer = OidcServer.available()) {
String sentinel = "SECRET_WRONG_AUDIENCE";
String token =
token(
PRIMARY_KEY, PRIMARY_KID, issuer.issuer(), sentinel, Instant.now().plusSeconds(300));
assertUnauthorized(issuer, token, "AUTH_AUDIENCE_MISMATCH", false, null, sentinel);
}
}
@Test
void wrongSignatureUsesExactSafeEnvelope() throws Exception {
try (OidcServer issuer = OidcServer.available()) {
String sentinel = "SECRET_WRONG_SIGNATURE";
String token =
token(
ALTERNATE_KEY,
PRIMARY_KID,
issuer.issuer(),
AUDIENCE,
Instant.now().plusSeconds(300));
assertUnauthorized(issuer, token, "AUTH_TOKEN_INVALID_SIGNATURE", false, null, sentinel);
}
}
@Test
void unknownKidUsesRetryableSafeEnvelope() throws Exception {
try (OidcServer issuer = OidcServer.available()) {
String sentinel = "SECRET_UNKNOWN_KID";
String token =
token(PRIMARY_KEY, sentinel, issuer.issuer(), AUDIENCE, Instant.now().plusSeconds(300));
assertUnauthorized(issuer, token, "AUTH_KID_UNKNOWN", true, "5", sentinel);
}
}
@Test
void jwksOutageRecoversInTheSameContextAfterRetryable503() throws Exception {
try (OidcServer issuer = OidcServer.jwksUnavailable()) {
String sentinel = "SECRET_JWKS_OUTAGE_TOKEN";
String token =
token(
PRIMARY_KEY, PRIMARY_KID, issuer.issuer(), AUDIENCE, Instant.now().plusSeconds(300));
withContext(
issuer,
mvc -> {
MvcResult unavailable = assertJwksUnavailable(mvc, token);
assertSafe(unavailable, token, sentinel, issuer.issuer(), PRIMARY_KID);
issuer.makeJwksAvailable();
mvc.perform(get("/protected").header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subject").value("user-1"));
});
assertThat(issuer.discoveryRequests()).isEqualTo(2);
assertThat(issuer.jwksRequests()).isEqualTo(2);
}
}
@Test
void mismatchedDiscoveryMetadataUsesSafeInternalMisconfigurationEnvelope() throws Exception {
try (OidcServer issuer = OidcServer.misconfiguredDiscovery()) {
String sentinel = "SECRET_DISCOVERY_ISSUER_DIAGNOSTIC";
String token =
token(
PRIMARY_KEY, PRIMARY_KID, issuer.issuer(), AUDIENCE, Instant.now().plusSeconds(300));
withContext(
issuer,
mvc -> {
MvcResult result =
mvc.perform(get("/protected").header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
.andExpect(status().isInternalServerError())
.andExpect(header().doesNotExist(HttpHeaders.WWW_AUTHENTICATE))
.andExpect(header().doesNotExist(HttpHeaders.RETRY_AFTER))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("INTERNAL_AUTH_MISCONFIGURATION"))
.andExpect(jsonPath("$.error.category").value("INTERNAL"))
.andExpect(jsonPath("$.error.retryable").value(false))
.andExpect(jsonPath("$.error.message").value("Authentication failed"))
.andReturn();
assertSafe(
result, token, sentinel, issuer.issuer(), issuer.discoveryIssuer(), PRIMARY_KID);
});
assertThat(issuer.discoveryRequests()).isEqualTo(1);
assertThat(issuer.jwksRequests()).isZero();
}
}
private static MvcResult assertJwksUnavailable(MockMvc mvc, String token) throws Exception {
return mvc.perform(get("/protected").header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
.andExpect(status().isServiceUnavailable())
.andExpect(header().doesNotExist(HttpHeaders.WWW_AUTHENTICATE))
.andExpect(header().string(HttpHeaders.RETRY_AFTER, "30"))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("AUTH_JWKS_UNAVAILABLE"))
.andExpect(jsonPath("$.error.category").value("TRANSIENT_DEPENDENCY"))
.andExpect(jsonPath("$.error.retryable").value(true))
.andExpect(
jsonPath("$.error.message").value("Authentication service temporarily unavailable"))
.andReturn();
}
private void assertUnauthorized(
OidcServer issuer,
String token,
String expectedCode,
boolean retryable,
String retryAfter,
String sentinel)
throws Exception {
withContext(
issuer,
mvc -> {
var action =
mvc.perform(get("/protected").header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
.andExpect(status().isUnauthorized())
.andExpect(
header()
.string(HttpHeaders.WWW_AUTHENTICATE, "Bearer error=\"invalid_token\""))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value(expectedCode))
.andExpect(jsonPath("$.error.category").value("AUTH"))
.andExpect(jsonPath("$.error.retryable").value(retryable));
if (retryAfter == null) {
action.andExpect(header().doesNotExist(HttpHeaders.RETRY_AFTER));
} else {
action.andExpect(header().string(HttpHeaders.RETRY_AFTER, retryAfter));
}
MvcResult result = action.andReturn();
assertSafe(result, token, sentinel, issuer.issuer(), PRIMARY_KID);
});
}
private void withContext(OidcServer issuer, ThrowingConsumer<MockMvc> assertion) {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=jwt",
"ca-skeleton.security.issuer-uri=" + issuer.issuer(),
"ca-skeleton.security.audience=" + AUDIENCE,
"ca-skeleton.security.public-paths=/public",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(issuer.discoveryRequests())
.as("issuer discovery must remain lazy")
.isZero();
assertThat(issuer.jwksRequests()).as("JWKS retrieval must remain lazy").isZero();
MockMvc mvc =
MockMvcBuilders.standaloneSetup(new ProbeController())
.addFilters(context.getBean("springSecurityFilterChain", Filter.class))
.build();
try {
assertion.accept(mvc);
} catch (Exception exception) {
throw new AssertionError("JWT/JWKS security boundary assertion failed", exception);
}
});
}
private static void assertSafe(MvcResult result, String... forbidden) throws Exception {
assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8))
.doesNotContain(forbidden);
String challenge = result.getResponse().getHeader(HttpHeaders.WWW_AUTHENTICATE);
if (challenge != null) {
assertThat(challenge).doesNotContain(forbidden);
}
}
private static String token(
KeyPair key, String kid, String issuer, String audience, Instant expiresAt) throws Exception {
Instant now = Instant.now();
JWTClaimsSet claims =
new JWTClaimsSet.Builder()
.subject("user-1")
.issuer(issuer)
.audience(audience)
.issueTime(Date.from(now.minusSeconds(300)))
.expirationTime(Date.from(expiresAt))
.claim("email", "user-1@example.test")
.claim("roles", List.of("operator"))
.build();
SignedJWT jwt =
new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).keyID(kid).build(),
claims);
jwt.sign(new RSASSASigner((RSAPrivateKey) key.getPrivate()));
return jwt.serialize();
}
private static KeyPair generateKeyPair() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (GeneralSecurityException exception) {
throw new ExceptionInInitializerError(exception);
}
}
@FunctionalInterface
private interface ThrowingConsumer<T> {
void accept(T value) throws Exception;
}
private static final class OidcServer implements AutoCloseable {
private final HttpServer server;
private final ExecutorService executor;
private volatile boolean unavailable;
private final String discoveryIssuer;
private final AtomicInteger discoveryRequests = new AtomicInteger();
private final AtomicInteger jwksRequests = new AtomicInteger();
private OidcServer(boolean unavailable, boolean misconfiguredDiscovery) throws IOException {
this.unavailable = unavailable;
InetAddress ipv4Loopback = InetAddress.getByAddress(new byte[] {127, 0, 0, 1});
server = HttpServer.create(new InetSocketAddress(ipv4Loopback, 0), 0);
executor =
Executors.newSingleThreadExecutor(
Thread.ofPlatform().daemon(true).name("oidc-test-server-", 0).factory());
server.setExecutor(executor);
server.createContext("/issuer/.well-known/openid-configuration", this::discovery);
server.createContext("/.well-known/openid-configuration/issuer", this::discovery);
server.createContext("/issuer/.well-known/oauth-authorization-server", this::discovery);
server.createContext("/issuer/jwks", this::jwks);
server.start();
discoveryIssuer =
misconfiguredDiscovery
? "https://SECRET_DISCOVERY_ISSUER_DIAGNOSTIC.invalid/issuer"
: issuer();
}
static OidcServer available() throws IOException {
return new OidcServer(false, false);
}
static OidcServer jwksUnavailable() throws IOException {
return new OidcServer(true, false);
}
static OidcServer misconfiguredDiscovery() throws IOException {
return new OidcServer(false, true);
}
String issuer() {
return "http://127.0.0.1:" + server.getAddress().getPort() + "/issuer";
}
int discoveryRequests() {
return discoveryRequests.get();
}
int jwksRequests() {
return jwksRequests.get();
}
String discoveryIssuer() {
return discoveryIssuer;
}
void makeJwksAvailable() {
unavailable = false;
}
private void discovery(HttpExchange exchange) throws IOException {
discoveryRequests.incrementAndGet();
String body =
"{\"issuer\":\"" + discoveryIssuer + "\",\"jwks_uri\":\"" + issuer() + "/jwks\"}";
respond(exchange, 200, body);
}
private void jwks(HttpExchange exchange) throws IOException {
jwksRequests.incrementAndGet();
if (unavailable) {
respond(exchange, 503, "{\"error\":\"temporarily_unavailable\"}");
return;
}
RSAPublicKey publicKey = (RSAPublicKey) PRIMARY_KEY.getPublic();
String body =
"{\"keys\":[{\"kty\":\"RSA\",\"use\":\"sig\",\"alg\":\"RS256\",\"kid\":\""
+ PRIMARY_KID
+ "\",\"n\":\""
+ base64Url(publicKey.getModulus())
+ "\",\"e\":\""
+ base64Url(publicKey.getPublicExponent())
+ "\"}]}";
respond(exchange, 200, body);
}
private static String base64Url(BigInteger value) {
byte[] encoded = value.toByteArray();
if (encoded.length > 1 && encoded[0] == 0) {
encoded = java.util.Arrays.copyOfRange(encoded, 1, encoded.length);
}
return Base64.getUrlEncoder().withoutPadding().encodeToString(encoded);
}
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] payload = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set(HttpHeaders.CONTENT_TYPE, "application/json");
exchange.sendResponseHeaders(status, payload.length);
try (OutputStream output = exchange.getResponseBody()) {
output.write(payload);
}
}
@Override
public void close() {
server.stop(0);
executor.shutdownNow();
}
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({SecuritySettings.class, CorsSettings.class})
static class PropertiesConfig {}
@RestController
static class ProbeController {
@GetMapping("/protected")
Map<String, Object> protectedEndpoint(Authentication authentication) {
AuthenticatedPrincipal principal = (AuthenticatedPrincipal) authentication.getPrincipal();
return Map.of("subject", principal.idpUserId(), "roles", principal.roles());
}
@GetMapping("/public")
String publicEndpoint() {
return "ok";
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Locale;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
class JwtToAuthenticatedPrincipalConverterTest {
@Test
void roleAuthoritiesUseLocaleIndependentUppercase() {
Locale originalDefault = Locale.getDefault();
Locale.setDefault(Locale.forLanguageTag("tr-TR"));
try {
Jwt jwt =
Jwt.withTokenValue("token")
.header("alg", "none")
.subject("user-1")
.claim("roles", List.of("admin"))
.build();
var authentication = new JwtToAuthenticatedPrincipalConverter().convert(jwt);
assertThat(authentication.getAuthorities())
.extracting(GrantedAuthority::getAuthority)
.containsExactly("ROLE_ADMIN");
} finally {
Locale.setDefault(originalDefault);
}
}
}
@@ -8,11 +8,14 @@ import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
@SuppressWarnings("deprecation")
class PrimitiveSessionSecurityContextRepositoryTest {
private final PrimitiveSessionSecurityContextRepository repository =
@@ -54,6 +57,116 @@ class PrimitiveSessionSecurityContextRepositoryTest {
.containsExactlyInAnyOrder("ROLE_OPERATOR", "worklog:read");
}
@Test
void savesThePrimitiveSnapshotBeforeAResponseCommitRequiresANewSession() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
var context = repository.loadContext(holder);
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal("commit-user", null, Set.of("operator")),
null,
Set.of(new SimpleGrantedAuthority("ROLE_OPERATOR"))));
try {
SecurityContextHolder.setContext(context);
holder.getResponse().flushBuffer();
assertThat(response.isCommitted()).isTrue();
assertThat(request.getSession(false)).isNotNull();
assertThat(
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isInstanceOf(byte[].class);
} finally {
SecurityContextHolder.clearContext();
}
}
@Test
void finalEmptyContextRemovesAnAuthenticatedSnapshotSavedAtCommit() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
var committedContext = authenticatedContext("committed-user");
try {
SecurityContextHolder.setContext(committedContext);
repository.loadContext(holder);
holder.getResponse().flushBuffer();
assertThat(request.getSession(false)).isNotNull();
repository.saveContext(
SecurityContextHolder.createEmptyContext(), holder.getRequest(), holder.getResponse());
assertThat(
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isNull();
} finally {
SecurityContextHolder.clearContext();
}
}
@Test
void finalReplacementContextOverridesTheSnapshotSavedAtCommit() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
try {
SecurityContextHolder.setContext(authenticatedContext("committed-user"));
repository.loadContext(holder);
holder.getResponse().flushBuffer();
repository.saveContext(
authenticatedContext("final-user"), holder.getRequest(), holder.getResponse());
MockHttpServletRequest nextRequest = new MockHttpServletRequest();
nextRequest.setSession((MockHttpSession) request.getSession(false));
var restored =
repository
.loadContext(
new HttpRequestResponseHolder(nextRequest, new MockHttpServletResponse()))
.getAuthentication();
assertThat(restored.getPrincipal())
.isEqualTo(new AuthenticatedPrincipal("final-user", null, Set.of("operator")));
} finally {
SecurityContextHolder.clearContext();
}
}
@Test
void asyncStartDefersCommitHookPersistenceUntilTheFinalContextSave() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAsyncSupported(true);
MockHttpServletResponse response = new MockHttpServletResponse();
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
SecurityContext finalContext = authenticatedContext("async-user");
try {
repository.loadContext(holder);
holder.getRequest().startAsync();
SecurityContextHolder.setContext(finalContext);
holder.getResponse().flushBuffer();
assertThat(request.getSession(false)).isNull();
repository.saveContext(finalContext, holder.getRequest(), holder.getResponse());
assertThat(
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isInstanceOf(byte[].class);
} finally {
SecurityContextHolder.clearContext();
}
}
@Test
void rejectsForeignPrincipalGraphsAndFailsClosedOnCorruptSnapshots() {
MockHttpServletRequest request = new MockHttpServletRequest();
@@ -100,4 +213,14 @@ class PrimitiveSessionSecurityContextRepositoryTest {
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("authorities");
}
private static SecurityContext authenticatedContext(String principalId) {
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(principalId, null, Set.of("operator")),
null,
Set.of(new SimpleGrantedAuthority("ROLE_OPERATOR"))));
return context;
}
}
@@ -6,6 +6,7 @@ import dev.caskeleton.shared.error.OperationalError;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
@@ -110,6 +111,15 @@ class SecurityErrorClassifierTest {
.isEqualTo(OperationalError.AUTH_KID_UNKNOWN);
}
@Test
void nimbusNoMatchingKeyMessageTakesKidPrecedenceOverGenericSignedJwtText() {
BadJwtException cause =
new BadJwtException(
"Signed JWT rejected: Another algorithm expected, or no matching key(s) found");
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
.isEqualTo(OperationalError.AUTH_KID_UNKNOWN);
}
@Test
void jwksEndpointOutageIsJwksUnavailable() {
JwtException cause =
@@ -120,6 +130,27 @@ class SecurityErrorClassifierTest {
}
@Test
void lazyDecoderDependencyFailureIsJwksUnavailable() {
JwtException cause =
new JwtDecoderConfig.AuthenticationKeyServiceUnavailableException(
new IllegalStateException("SECRET_REMOTE_JWK_DIAGNOSTIC"));
assertThat(classify(new AuthenticationServiceException("safe", cause)))
.isEqualTo(OperationalError.AUTH_JWKS_UNAVAILABLE);
}
@Test
void lazyDecoderConfigurationFailureIsInternalMisconfiguration() {
JwtException cause =
new JwtDecoderConfig.AuthenticationDecoderMisconfigurationException(
new IllegalStateException("SECRET_CONFIGURATION_DIAGNOSTIC"));
assertThat(classify(new AuthenticationServiceException("safe", cause)))
.isEqualTo(OperationalError.INTERNAL_AUTH_MISCONFIGURATION);
}
@Test
// The anonymous subclass exists only to be a type the classifier has never seen; it is
// constructed, classified, and discarded, never serialized.
@SuppressWarnings("serial")
void unknownAuthenticationFailureFallsBackToMalformed() {
// A novel/unmapped AuthenticationException must never leak as a 500; the safe default
// is a generic 401 AUTH classification rather than an unclassified error.
@@ -90,6 +90,27 @@ class SecurityModeWebContractTest {
});
}
@Test
void redisSessionModeDoesNotCacheUnauthorizedApiRequestsInAFrameworkSessionObject() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.public-paths=/csrf",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
var result =
mvc.perform(get("/whoami")).andExpect(status().isUnauthorized()).andReturn();
assertThat(result.getRequest().getSession(false)).isNull();
} catch (Exception exception) {
throw new AssertionError("unauthorized request-cache contract failed", exception);
}
});
}
@Test
void redisSessionSecurityFilterPersistsAndRestoresOnlyThePrimitiveAuthenticationSnapshot() {
runner
@@ -28,6 +28,19 @@ class ETagsTest {
assertThat(ETags.matches("W/\"1\", W/\"2\", W/\"3\"", ETags.weakFromVersion(2))).isTrue();
}
@Test
void quotedOpaqueValuesContainingCommasRemainSingleCandidates() {
String current = "W/\"opaque,tag\"";
assertThat(ETags.matches("\"opaque,tag\"", current)).isTrue();
assertThat(ETags.matches("W/\"other\", W/\"opaque,tag\", \"else\"", current)).isTrue();
}
@Test
void malformedUnclosedQuotedCandidateDoesNotMatchAValidEtag() {
assertThat(ETags.matches("W/\"opaque\", W/\"other", "W/\"opaque\"")).isFalse();
}
@Test
void staleVersionDoesNotMatch() {
assertThat(ETags.matches("W/\"1\"", ETags.weakFromVersion(2))).isFalse();
@@ -3,6 +3,7 @@ package dev.caskeleton.adapter.inbound.web.cursor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
@@ -49,6 +50,8 @@ class CursorCodecTest {
@Test
void shortKeyIsRefused() {
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> new CursorCodec("short".getBytes(), CursorCodec.DEFAULT_TTL));
.isThrownBy(
() ->
new CursorCodec("short".getBytes(StandardCharsets.UTF_8), CursorCodec.DEFAULT_TTL));
}
}
@@ -1,10 +1,16 @@
package dev.caskeleton.adapter.inbound.web.error;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException;
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException;
import dev.caskeleton.shared.error.AdapterDisabledException;
import dev.caskeleton.shared.error.DependencyFailureException;
import dev.caskeleton.shared.error.MappingException;
@@ -12,15 +18,33 @@ import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.shared.error.PersistenceFailureException;
import dev.caskeleton.shared.response.Envelope;
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Path;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.metadata.ConstraintDescriptor;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;
class GlobalExceptionHandlerTest {
@@ -28,8 +52,8 @@ class GlobalExceptionHandlerTest {
@Test
void mappingExceptionRoutesToMappingFailedEnvelope() {
ResponseEntity<Envelope<Void>> response =
handler.handleMapping(new MappingException("cannot map field 'role'"));
String secret = "SECRET_MAPPING_DIAGNOSTIC";
ResponseEntity<Envelope<Void>> response = handler.handleMapping(new MappingException(secret));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
Envelope<Void> body = response.getBody();
@@ -37,26 +61,31 @@ class GlobalExceptionHandlerTest {
assertThat(body.success()).isFalse();
assertThat(body.error().code()).isEqualTo("MAPPING_FAILED");
assertThat(body.error().category()).isEqualTo("VALIDATION");
assertThat(body.error().message()).isEqualTo("cannot map field 'role'");
assertThat(body.error().message()).isEqualTo("Request data could not be mapped");
assertThat(body.error().message()).doesNotContain(secret);
assertThat(body.error().retryable()).isFalse();
}
@Test
void illegalArgumentMapsToBadParameterEnvelope() {
String secret = "SECRET_INVALID_ACCOUNT_VALUE";
ResponseEntity<Envelope<Void>> response =
handler.handleIllegalArgument(new IllegalArgumentException("bad offset"));
handler.handleIllegalArgument(new IllegalArgumentException(secret));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().error().code()).isEqualTo("BAD_PARAMETER");
assertThat(response.getBody().error().message()).isEqualTo("Request parameter is invalid");
assertThat(response.getBody().error().message()).doesNotContain(secret);
}
@Test
void adapterDisabledMapsToAdapterDisabled500NotRetryable() {
// integration-adapter-templates Layer 3 / §Audit A2 — runtime fail-fast code,
// distinct from the startup REQUIRED_ADAPTER_DISABLED.
String secret = "SECRET_INTERNAL_BROKER_ENDPOINT";
ResponseEntity<Envelope<Void>> response =
handler.handleAdapterDisabled(new AdapterDisabledException("kafka"));
handler.handleAdapterDisabled(new AdapterDisabledException("kafka", secret));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody()).isNotNull();
@@ -64,6 +93,174 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().error().code()).isEqualTo("ADAPTER_DISABLED");
assertThat(response.getBody().error().category()).isEqualTo("INTERNAL");
assertThat(response.getBody().error().retryable()).isFalse();
assertThat(response.getBody().error().message()).isEqualTo("Internal server error");
assertThat(response.getBody().error().message()).doesNotContain(secret);
}
@Test
void securityAndConditionalExceptionsUseFixedMessages() {
String secret = "SECRET_SECURITY_DIAGNOSTIC";
List<ResponseEntity<Envelope<Void>>> responses =
List.of(
handler.handleInvalidToken(new InvalidBearerTokenException(secret)),
handler.handleUnauthenticated(new BadCredentialsException(secret)),
handler.handleForbidden(new AccessDeniedException(secret)),
handler.handlePreconditionFailed(new PreconditionFailedException(secret)));
assertThat(responses)
.extracting(response -> response.getBody().error().message())
.containsExactly(
"Authentication token is invalid",
"Authentication is required",
"Access is denied",
"Resource state changed; refresh and retry");
assertThat(responses)
.allSatisfy(
response -> {
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().error().message()).doesNotContain(secret);
});
}
@Test
void paginationAndCursorExceptionsUseSafeDetailsAndFixedMessages() {
String secret = "SECRET_CURSOR_OR_FILTER_VALUE";
ResponseEntity<Envelope<Void>> page =
handler.handlePageValidation(
new PageValidationException("size", "SIZE_EXCEEDS_MAX", secret));
ResponseEntity<Envelope<Void>> cursor = handler.handleCursor(new CursorException(secret));
assertThat(page.getBody()).isNotNull();
assertThat(page.getBody().error().message()).isEqualTo("Pagination parameter is invalid");
assertThat(page.getBody().error().details())
.isEqualTo(Map.of("field", "size", "code", "SIZE_EXCEEDS_MAX"));
assertThat(cursor.getBody()).isNotNull();
assertThat(cursor.getBody().error().message())
.isEqualTo("Cursor is invalid or expired; re-request the first page");
assertThat(page.getBody().error().toString()).doesNotContain(secret);
assertThat(cursor.getBody().error().toString()).doesNotContain(secret);
}
@Test
void typeMismatchDoesNotEchoRejectedParameterValue() {
String secret = "SECRET_PATH_OR_QUERY_VALUE";
MethodArgumentTypeMismatchException exception =
new MethodArgumentTypeMismatchException(
secret,
Long.class,
"accountId",
validationProbeParameter(),
new NumberFormatException(secret));
ResponseEntity<Envelope<Void>> response = handler.handleTypeMismatch(exception);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().error().message()).isEqualTo("Parameter 'accountId' is invalid");
assertThat(response.getBody().error().details()).isEqualTo(Map.of("expectedType", "Long"));
assertThat(response.getBody().error().toString()).doesNotContain(secret);
}
@Test
void bodyValidationUsesFixedReasonAndStripsRequestControlledPathParts() {
String secret = "SECRET_REQUEST_BODY_VALUE";
BeanPropertyBindingResult bindingResult =
new BeanPropertyBindingResult(new Object(), "request");
bindingResult.addError(
new FieldError(
"request",
"attributes[" + secret + "].passwords[7]",
secret,
false,
new String[] {"Pattern"},
null,
"rejected interpolated value " + secret));
MethodArgumentNotValidException exception =
new MethodArgumentNotValidException(validationProbeParameter(), bindingResult);
ResponseEntity<Object> response =
handler.handleMethodArgumentNotValid(
exception,
new HttpHeaders(),
HttpStatus.BAD_REQUEST,
new ServletWebRequest(new MockHttpServletRequest()));
assertThat(response.getBody()).isInstanceOf(Envelope.class);
Envelope<?> envelope = (Envelope<?>) response.getBody();
assertThat(envelope.error().details()).isInstanceOf(List.class);
List<?> details = (List<?>) envelope.error().details();
assertThat(details).hasSize(1);
assertThat(details.getFirst())
.isEqualTo(
Map.of(
"field", "attributes.passwords",
"code", "PATTERN",
"message", "Value has an invalid format"));
assertThat(envelope.error().toString())
.doesNotContain(secret, "rejectedValue", "rejected interpolated value", "[7]");
}
@Test
@SuppressWarnings("unchecked")
void constraintValidationUsesFixedReasonAndStripsRequestControlledMapKey() {
String secret = "SECRET_MAP_KEY_AND_VALIDATED_VALUE";
ConstraintViolation<Object> violation = mock(ConstraintViolation.class);
Path path = mock(Path.class);
Path.Node attributes = mock(Path.Node.class);
Path.Node value = mock(Path.Node.class);
ConstraintDescriptor<?> descriptor = mock(ConstraintDescriptor.class);
Pattern constraint = mock(Pattern.class);
when(path.toString()).thenReturn("attributes[" + secret + "].value[3]");
when(path.iterator()).thenAnswer(ignored -> List.of(attributes, value).iterator());
when(attributes.getName()).thenReturn("attributes");
when(attributes.getKey()).thenReturn(secret);
when(value.getName()).thenReturn("value");
when(value.getIndex()).thenReturn(3);
when(violation.getPropertyPath()).thenReturn(path);
when(violation.getMessage()).thenReturn("rejected interpolated value " + secret);
// getAnnotation() and annotationType() are declared with wildcards, so `when(...).thenReturn`
// has to infer through a capture and needs a raw cast to compile. doReturn takes Object and
// sidesteps the inference entirely — same stubbing, no cast, and no compiler is left to
// disagree about the capture.
doReturn(constraint).when(descriptor).getAnnotation();
doReturn(descriptor).when(violation).getConstraintDescriptor();
doReturn(Pattern.class).when(constraint).annotationType();
ResponseEntity<Envelope<Void>> response =
handler.handleConstraintViolation(new ConstraintViolationException(Set.of(violation)));
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().error().details()).isInstanceOf(List.class);
List<?> details = (List<?>) response.getBody().error().details();
assertThat(details).hasSize(1);
assertThat(details.getFirst())
.isEqualTo(
Map.of(
"field", "attributes.value",
"code", "PATTERN",
"message", "Value has an invalid format"));
assertThat(response.getBody().error().toString())
.doesNotContain(secret, "rejected interpolated value", "[3]");
}
@Test
void routeNotFoundDoesNotEchoRawRequestUrl() {
String secret = "SECRET_URL_SEGMENT";
NoHandlerFoundException exception =
new NoHandlerFoundException("GET", "/reset/" + secret, new HttpHeaders());
ResponseEntity<Object> response =
handler.handleNoHandlerFoundException(
exception,
new HttpHeaders(),
HttpStatus.NOT_FOUND,
new ServletWebRequest(new MockHttpServletRequest()));
assertThat(response.getBody()).isInstanceOf(Envelope.class);
Envelope<?> envelope = (Envelope<?>) response.getBody();
assertThat(envelope.error().message()).isEqualTo("Requested route was not found");
assertThat(envelope.error().toString()).doesNotContain(secret);
}
@Test
@@ -415,4 +612,17 @@ class GlobalExceptionHandlerTest {
.as("D12: no raw diagnostic detail in error.details")
.isNull();
}
private static MethodParameter validationProbeParameter() {
try {
Method method =
GlobalExceptionHandlerTest.class.getDeclaredMethod("validationProbe", Object.class);
return new MethodParameter(method, 0);
} catch (NoSuchMethodException exception) {
throw new AssertionError(exception);
}
}
@SuppressWarnings("unused")
private static void validationProbe(Object body) {}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.inbound.web.error;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Exercises the real MVC resource resolver rather than invoking the advice method directly. */
@WebMvcTest(
controllers = NoResourceFoundErrorHandlingTest.Probe.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({
NoResourceFoundErrorHandlingTest.Probe.class,
GlobalExceptionHandler.class,
EnvelopeBodyAdvice.class
})
class NoResourceFoundErrorHandlingTest {
@Autowired MockMvc mvc;
@Test
void missingResourceUsesSafeRouteNotFoundEnvelope() throws Exception {
String secret = "SECRET_RESET_RESOURCE_TOKEN";
mvc.perform(get("/assets/" + secret + ".js"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("ROUTE_NOT_FOUND"))
.andExpect(jsonPath("$.error.category").value("NOT_FOUND"))
.andExpect(jsonPath("$.error.retryable").value(false))
.andExpect(jsonPath("$.error.message").value("Requested route was not found"))
.andExpect(content().string(not(containsString(secret))));
}
@RestController
static class Probe {
@GetMapping("/probe")
String probe() {
return "ok";
}
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
static class TestBootstrap {}
}
@@ -1,8 +1,10 @@
package dev.caskeleton.adapter.inbound.web.error;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -56,9 +58,13 @@ class TransportErrorHandlingTest {
@Test
void unsupportedMediaTypeIs415() throws Exception {
mvc.perform(post("/t/json").contentType(MediaType.TEXT_PLAIN).content("hello"))
mvc.perform(
post("/t/json")
.contentType("application/x-private+json;profile=SECRET_MEDIA_PROFILE")
.content("hello"))
.andExpect(status().isUnsupportedMediaType())
.andExpect(jsonPath("$.error.code").value("UNSUPPORTED_MEDIA_TYPE"));
.andExpect(jsonPath("$.error.code").value("UNSUPPORTED_MEDIA_TYPE"))
.andExpect(content().string(not(containsString("SECRET_MEDIA_PROFILE"))));
}
@Test

Some files were not shown because too many files have changed in this diff Show More