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:
co-authored by
Claude Opus 5
parent
1a3b560678
commit
5f10b791d3
+16
-7
@@ -27,7 +27,7 @@ FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694b
|
||||
ARG RELEASE_VERSION
|
||||
ARG GIT_SHA
|
||||
|
||||
WORKDIR /build
|
||||
WORKDIR /build/src
|
||||
|
||||
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
||||
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
||||
@@ -38,6 +38,7 @@ WORKDIR /build
|
||||
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
||||
COPY gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY config/ ./config/
|
||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||
|
||||
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
|
||||
@@ -48,14 +49,11 @@ RUN test -n "${RELEASE_VERSION}" \
|
||||
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Copy full source and build the JAR
|
||||
# Copy full source and stage the executable JAR at Gradle's declared Docker output path.
|
||||
COPY . .
|
||||
RUN ./gradlew :app-bootstrap:bootJar --no-daemon -x test \
|
||||
RUN ./gradlew :app-bootstrap:stageDockerJar --no-daemon -x test \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Locate the produced JAR (avoids hardcoding the version string)
|
||||
RUN cp $(ls app-bootstrap/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
|
||||
|
||||
# ---- Stage 2: runtime image -------------------------------------------------
|
||||
# JRE-only slim image (D3: no full JDK in production image).
|
||||
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
|
||||
@@ -114,9 +112,20 @@ RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
|
||||
RUN groupadd --system --gid 1000 app \
|
||||
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
|
||||
|
||||
# ---- Fileserver storage root ------------------------------------------------
|
||||
# Created in the image with the runtime user's ownership and 0750, so a fresh named volume
|
||||
# mounted here inherits both. Without it the Fileserver platform's default root does not exist
|
||||
# on a read-only root filesystem, and the capability fails on its first upload rather than at
|
||||
# startup. This directory is a mount point, not a place to keep data in the image: an unmounted
|
||||
# container writes into the container layer and loses everything on replacement.
|
||||
RUN mkdir -p /var/lib/backend/files \
|
||||
&& chown app:app /var/lib/backend/files \
|
||||
&& chmod 0750 /var/lib/backend/files
|
||||
VOLUME ["/var/lib/backend/files"]
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=app:app /build/app.jar app.jar
|
||||
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/application.jar app.jar
|
||||
|
||||
USER app
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694b
|
||||
ARG RELEASE_VERSION
|
||||
ARG GIT_SHA
|
||||
|
||||
WORKDIR /build
|
||||
WORKDIR /build/src
|
||||
|
||||
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
||||
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
||||
@@ -49,6 +49,7 @@ WORKDIR /build
|
||||
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
||||
COPY gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY config/ ./config/
|
||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||
|
||||
# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either).
|
||||
@@ -57,14 +58,11 @@ RUN test -n "${RELEASE_VERSION}" \
|
||||
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Copy full source and build the sample JAR.
|
||||
# Copy full source and stage the executable sample JAR at Gradle's declared Docker output path.
|
||||
COPY . .
|
||||
RUN ./gradlew :sample-portfolio:bootJar --no-daemon -x test \
|
||||
RUN ./gradlew :sample-portfolio:stageDockerJar --no-daemon -x test \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Locate the produced JAR (avoids hardcoding the version string).
|
||||
RUN cp $(ls sample-portfolio/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
|
||||
|
||||
# ---- Stage 2: runtime image -------------------------------------------------
|
||||
# JRE-only slim image (no full JDK in the demo image either).
|
||||
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
|
||||
@@ -110,7 +108,7 @@ RUN groupadd --system --gid 1000 app \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=app:app /build/app.jar app.jar
|
||||
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/application.jar app.jar
|
||||
|
||||
USER app
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 제품
|
||||
표면을 채택할 때 별도 설계·테스트와 함께 추가해야 한다.
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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!
|
||||
|
||||
+1
@@ -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) {
|
||||
|
||||
+266
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
extend type Query {
|
||||
carrierFailure: String
|
||||
unknownFailure: String
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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에서 별도
|
||||
설계·검증해야 한다.
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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
|
||||
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+62
-27
@@ -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;
|
||||
}
|
||||
|
||||
+11
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+63
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-9
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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) {
|
||||
|
||||
+355
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+51
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
+73
-1
@@ -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;
|
||||
|
||||
+41
@@ -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]);
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -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")
|
||||
|
||||
+37
-4
@@ -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. */
|
||||
|
||||
+3
@@ -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 -> {
|
||||
|
||||
+34
@@ -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";
|
||||
|
||||
+130
@@ -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) {}
|
||||
}
|
||||
+44
-32
@@ -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()));
|
||||
}
|
||||
|
||||
+137
@@ -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());
|
||||
}
|
||||
}
|
||||
+7
@@ -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) {}
|
||||
+16
@@ -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) {}
|
||||
+96
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+165
@@ -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;
|
||||
}
|
||||
}
|
||||
+225
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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() {}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+178
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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();
|
||||
}
|
||||
+16
@@ -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());
|
||||
}
|
||||
}
|
||||
+24
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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) {}
|
||||
+30
@@ -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());
|
||||
}
|
||||
}
|
||||
+51
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -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();
|
||||
}
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+72
@@ -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));
|
||||
}
|
||||
}
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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();
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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) {}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
+54
@@ -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");
|
||||
}
|
||||
}
|
||||
+150
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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"));
|
||||
}
|
||||
}
|
||||
+165
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
+108
@@ -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();
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+93
@@ -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));
|
||||
}
|
||||
}
|
||||
+71
@@ -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();
|
||||
}
|
||||
}
|
||||
+19
@@ -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() {}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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;
|
||||
}
|
||||
}
|
||||
+53
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -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() {}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+140
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+233
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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
|
||||
|
||||
+455
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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.
|
||||
|
||||
+21
@@ -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
|
||||
|
||||
+13
@@ -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();
|
||||
|
||||
+4
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+215
-5
@@ -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) {}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user