refactor: 각 어댑터터별 리펙토링 진행
This commit is contained in:
@@ -331,6 +331,8 @@ APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK=false
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=false
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES=65508
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW=5m
|
||||
# Empty: forwarded headers are not believed. List load-balancer peers to honour them.
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES=
|
||||
|
||||
# ---- Secrets — supply out of band; never commit a value here ------------------
|
||||
APP_DATASOURCE_PASSWORD=
|
||||
|
||||
@@ -40,6 +40,11 @@ COPY gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY config/ ./config/
|
||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||
# The convention plugins, whole. The glob above copies files named build.gradle and gradle.lockfile,
|
||||
# which picks up build-logic's own build script and misses the precompiled script plugins beside it —
|
||||
# so settings.gradle's `includeBuild('build-logic')` resolved against a build that declared no
|
||||
# plugins and every leaf failed on an unknown plugin id, in the dependency-resolution stage below.
|
||||
COPY build-logic/ ./build-logic/
|
||||
|
||||
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
|
||||
# custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries
|
||||
|
||||
+63
-5
@@ -241,6 +241,59 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
||||
- **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**.
|
||||
1 이상 정수.
|
||||
|
||||
### 다섯 master switch (activation SSOT)
|
||||
|
||||
한 bootJar가 다섯 어댑터를 모두 싣고, 각각은 아래 스위치 하나로만 켜집니다. **전부 기본 `false`** 이고,
|
||||
다섯이 모두 꺼진 배포는 외부 자원 없이 기동합니다. 값의 SSOT는
|
||||
`dev.caskeleton.shared.activation.MasterSwitch`이며 `docs/registries/env-keys.yaml`이 같은 이름을
|
||||
등록합니다.
|
||||
|
||||
| 환경 변수 | Spring property | 기본값 |
|
||||
|---|---|---|
|
||||
| `APP_PERSISTENCE_JPA_ENABLED` | `ca-skeleton.persistence-jpa.enabled` | `false` |
|
||||
| `APP_PERSISTENCE_MONGO_ENABLED` | `ca-skeleton.persistence-mongo.enabled` | `false` |
|
||||
| `APP_MESSAGING_ENABLED` | `app.messaging.enabled` | `false` |
|
||||
| `APP_NOTIFICATION_PLATFORM_ENABLED` | `ca-skeleton.notification.platform.enabled` | `false` |
|
||||
| `APP_GRAPHQL_ENABLED` | `backend.graphql.enabled` | `false` |
|
||||
|
||||
**어댑터가 꺼진 것과 없는 것은 다릅니다.** 다섯 어댑터의 클래스는 언제나 아티팩트 안에 있고, 운영자는
|
||||
재빌드 없이 스위치만으로 켭니다. 클래스가 없으면 애초에 켤 수 없습니다.
|
||||
|
||||
켜진 capability가 의존하는 것이 꺼져 있으면 기동이 거부되고, **거부 메시지는 설정해야 할 정확한
|
||||
property 이름을 말합니다**(`CapabilityDependencyValidator`). 예:
|
||||
|
||||
```
|
||||
This deployment enables capabilities whose dependencies are off:
|
||||
- ca-skeleton.outbox.enabled=true needs relational persistence to store rows;
|
||||
set ca-skeleton.persistence-jpa.enabled=true or turn the outbox off.
|
||||
```
|
||||
|
||||
종속 선택자 둘:
|
||||
|
||||
- `APP_GRAPHQL_DEPLOYMENT_MODE` — GraphQL이 켜지면 **필수**이고 기본값이 없습니다. 예전의 boolean과
|
||||
enum 두 기본값이 서로 다른 말을 했기 때문에, 안전 태세는 배포가 명시적으로 고릅니다. 허용되는 값은
|
||||
런타임 환경(`local`/`dev`/`prod`)마다 다릅니다.
|
||||
- `APP_NOTIFICATION_PLATFORM_MODE` — `SERVING`(기본) 또는 `INGEST_ONLY`. 선택 사항이고, 허용 값은
|
||||
`NotificationModeSsotTest`가 enum에서 파생합니다.
|
||||
|
||||
### Compose와 런타임 스모크
|
||||
|
||||
- **Docker Compose 최소 버전 `2.24.4`.** SSOT는 `src/config/runtime/compose-profile-contracts.json`
|
||||
이고, 정본 스크립트가 검증합니다.
|
||||
- 진입점은 둘뿐이고, 그 둘만이 증거입니다. 워크플로에 명령 일부를 인라인하면 one-shot 없이 도는 레인이
|
||||
초록으로 보고됩니다.
|
||||
|
||||
```bash
|
||||
# 정적: profile별 정확한 service set, 병합된 모델 전체, mount target 유일성
|
||||
./scripts/verify-compose-profile-contracts.sh
|
||||
|
||||
# 동적: 15개 blocking 레인을 zero-skip으로. create → up --wait → 필수 one-shot →
|
||||
# sanitized evidence → 고유 project teardown
|
||||
./scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json
|
||||
```
|
||||
|
||||
`--lane <id>`는 실패 재현용이고, 레인 하나가 초록인 것은 matrix가 통과했다는 증거가 아닙니다.
|
||||
|
||||
### Optional integration adapters
|
||||
|
||||
선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택
|
||||
@@ -252,13 +305,18 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3).
|
||||
`disabled`(기본) | `redis`. `redis`는 canonical Redis CACHE role binding을 함께 요구합니다.
|
||||
- **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가
|
||||
제공한 `RedisClient` bean을 사용합니다.
|
||||
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
|
||||
fail-fast).
|
||||
아래 세 키는 **활성화 스위치가 아니라 선택자**입니다. 어떤 capability가 켜지는지는 위의 master switch가
|
||||
정하고, 이 값들은 켜진 capability가 *무엇으로* 동작할지만 고릅니다. 예전에는 "빈 값 = 비활성"으로
|
||||
설명돼 있었고, 그 문장이 남아 있는 동안 두 개의 활성화 모델이 공존했습니다.
|
||||
|
||||
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). `APP_MESSAGING_ENABLED=true`일 때
|
||||
**필수**이고, 빈 값이면 기동이 거부되면서 이 키 이름을 지목합니다. 이 키를 비워도 메시징이 꺼지지는
|
||||
않습니다 — 끄는 것은 master switch입니다.
|
||||
- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수,
|
||||
아니면 빈 값.
|
||||
- **`APP_NOTIFICATION_SLACK_PROVIDER`** — 활성 Slack provider id(예: `webhook`). 빈 값 = Slack 비활성.
|
||||
- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — 활성 email provider id(예: `google-email`). 빈 값 = email
|
||||
비활성.
|
||||
- **`APP_NOTIFICATION_SLACK_PROVIDER`** — Slack provider id(예: `webhook`). notification 플랫폼이
|
||||
켜졌을 때 어떤 provider를 쓸지 고르는 값입니다.
|
||||
- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — email provider id(예: `google-email`). 위와 같습니다.
|
||||
|
||||
### Outbound HTTP client
|
||||
|
||||
|
||||
@@ -69,7 +69,10 @@ SSOT(`src/config/architecture/modules.json`)까지 밀어올리지 않는다는
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`. `:application-core` 는 선언만이 아니라 실제
|
||||
의존이다 — object 인가의 **답하는 계약**(`dev.caskeleton.application.security.ObjectAccessPolicy`)이
|
||||
거기 살기 때문이다. 이 leaf 가 그 계약을 소유했다면 application 구현체가 인바운드 전송을 컴파일
|
||||
의존해야 했고, 그건 의존 방향이 뒤집힌다.
|
||||
- `spring-boot-starter-graphql` (Spring Boot BOM 관리 — 버전 명시 없음).
|
||||
- test scope 에 한해 `spring-boot-starter-web`(random-port 전송 테스트용),
|
||||
`spring-boot-starter-security`(HTTP 인증/CORS qualification 용),
|
||||
@@ -140,7 +143,9 @@ health 스키마만 소유한다.
|
||||
| preparsed document cache (`execution/`) | `wired` | `GraphQlPreparsedDocumentAdapter` + 같은 테스트의 캐시 hit 케이스 |
|
||||
| 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 |
|
||||
| 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` |
|
||||
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrar` + `dataloader/GraphQlBatchContractTest` |
|
||||
| 관측 tag cardinality (`observation/`) | `wired` | `runtime/GraphQlRequestObservationConventionAdapter` 가 Spring 의 `ExecutionRequestObservationConvention` 을 구현해 Boot 의 `GraphQlObservationAutoConfiguration` 이 이 컨벤션을 가져간다. `autoconfigure/GraphQlObservationWiringTest`(프레임워크가 실제로 해석), `runtime/GraphQlRequestObservationConventionAdapterTest`(실제 `MeterRegistry` 에 임의 이름 10,000개 → series 1개) |
|
||||
| object 인가 (`security/`) | `modelled` | 답하는 계약은 중립 `dev.caskeleton.application.security.ObjectAccessPolicy` 가 소유하고 이 leaf 는 `ApplicationObjectAuthorization` 매핑만 가진다. 실행 경로에 연결하는 configuration 은 없다 |
|
||||
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrationTest` (실제 graphql-java 실행 + Spring `BatchLoaderRegistry`, 50 parent → 3 downstream 호출), `dataloader/GraphQlBatchContractTest` |
|
||||
| cursor 서명 (`pagination/`) | `modelled` | `HmacGraphQlCursorCodec`·`GraphQlCursorKeyRing` 단위 테스트만. **auto-configuration 이 둘 중 무엇도 생성하지 않는다** — `autoconfigure/GraphQlPolicyRequestPathTest` 가 그 사실을 고정 |
|
||||
| mutation 멱등성 (`mutation/`) | `modelled` | `GraphQlMutationIdempotencyInterceptor` 를 참조하는 configuration 이 없다. 같은 테스트가 고정 |
|
||||
| persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 |
|
||||
@@ -190,7 +195,7 @@ cd src
|
||||
`quarantine`·`graphql-performance` 태그를 제외한다:
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 551 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 605 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 152 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요
|
||||
@@ -199,7 +204,7 @@ cd src
|
||||
`graphqlPerformanceTest` 는 `@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는
|
||||
버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다.
|
||||
|
||||
위 숫자는 `build/test-results/<lane>/*.xml` 의 실제 실행 결과다(기본 `test` 703, transport
|
||||
위 숫자는 `build/test-results/<lane>/*.xml` 의 실제 실행 결과다(기본 `test` 757, transport
|
||||
qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 —
|
||||
컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다.
|
||||
|
||||
|
||||
@@ -19,12 +19,17 @@ description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL executi
|
||||
// the second half rather than trusting it.
|
||||
apply plugin: 'java-test-fixtures'
|
||||
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation project(':shared-contract')
|
||||
|
||||
// The object-access rule this platform consults belongs to the application, and so does its
|
||||
// contract: an inbound adapter that declared it would force every implementation to compile
|
||||
// against this transport. The leaf keeps only the mapping from a GraphQL request context to
|
||||
// the four plain values that contract speaks.
|
||||
implementation project(':application-core')
|
||||
|
||||
// The fixtures exercise the platform through the same contracts an adopter uses.
|
||||
testFixturesImplementation project(':shared-contract')
|
||||
testFixturesImplementation 'org.springframework.boot:spring-boot-starter-graphql'
|
||||
@@ -152,95 +157,14 @@ tasks.named('check') {
|
||||
// prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant
|
||||
// to use, and everything else in this file is a candidate for becoming internal when the leaf is
|
||||
// split into capability artifacts. Until then the number cannot grow by accident.
|
||||
def graphQlApiSurfaceFile = rootProject.file('../docs/architecture/graphql-api-surface.txt')
|
||||
|
||||
Closure<String> renderGraphQlApiSurface = {
|
||||
def sourceRoot = file('src/main/java')
|
||||
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
|
||||
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
|
||||
List<String> types = []
|
||||
sourceRoot.eachFileRecurse { candidate ->
|
||||
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
|
||||
return
|
||||
}
|
||||
String text = candidate.getText('UTF-8')
|
||||
def packageMatcher = packagePattern.matcher(text)
|
||||
if (!packageMatcher.find()) {
|
||||
return
|
||||
}
|
||||
String packageName = packageMatcher.group(1)
|
||||
def typeMatcher = typePattern.matcher(text)
|
||||
while (typeMatcher.find()) {
|
||||
types << "${packageName}.${typeMatcher.group(2)}".toString()
|
||||
}
|
||||
}
|
||||
types = types.unique().toSorted()
|
||||
String header =
|
||||
"# GraphQL leaf public API surface — every public top-level type in src/main/java.\n" +
|
||||
"# A public type in a single-jar leaf is reachable from every adopter's code, so\n" +
|
||||
"# additions are reviewed rather than discovered. `api` and `spi` are the intended\n" +
|
||||
"# external surface; the rest are candidates to become internal when this leaf is\n" +
|
||||
"# split into capability artifacts.\n" +
|
||||
"# Update only after review with:\n" +
|
||||
"# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange\n" +
|
||||
"# types: ${types.size()}\n"
|
||||
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
|
||||
}
|
||||
|
||||
// The approval flag is read at configuration time and carried in, not fetched from `project`
|
||||
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
|
||||
// incompatible with the configuration cache — which this build will need before it can adopt one.
|
||||
boolean graphQlApiSurfaceUpdateApproved = project.hasProperty('approveGraphQlApiSurfaceChange')
|
||||
|
||||
tasks.register('verifyGraphQlApiSurface') {
|
||||
group = 'verification'
|
||||
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
|
||||
|
||||
doLast {
|
||||
if (graphQlApiSurfaceUpdateApproved) {
|
||||
throw new GradleException(
|
||||
'verifyGraphQlApiSurface is read-only; use updateGraphQlApiSurface to record an ' +
|
||||
'approved change.')
|
||||
}
|
||||
String rendered = renderGraphQlApiSurface()
|
||||
if (!graphQlApiSurfaceFile.isFile()) {
|
||||
throw new GradleException(
|
||||
"verifyGraphQlApiSurface: missing committed baseline ${graphQlApiSurfaceFile}")
|
||||
}
|
||||
String committed = graphQlApiSurfaceFile.getText('UTF-8')
|
||||
if (committed != rendered) {
|
||||
List<String> committedTypes = committed.readLines().findAll { !it.startsWith('#') }
|
||||
List<String> renderedTypes = rendered.readLines().findAll { !it.startsWith('#') }
|
||||
List<String> added = (renderedTypes - committedTypes).toSorted()
|
||||
List<String> removed = (committedTypes - renderedTypes).toSorted()
|
||||
throw new GradleException(
|
||||
"verifyGraphQlApiSurface: the public API surface changed.\n" +
|
||||
(added.isEmpty() ? '' : " added:\n " + added.join('\n ') + '\n') +
|
||||
(removed.isEmpty() ? '' : " removed:\n " + removed.join('\n ') + '\n') +
|
||||
"Review the change, then record it with:\n" +
|
||||
" ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface " +
|
||||
"-PapproveGraphQlApiSurfaceChange")
|
||||
}
|
||||
logger.lifecycle('verifyGraphQlApiSurface: OK — the committed public API surface is unchanged.')
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('updateGraphQlApiSurface') {
|
||||
group = 'verification'
|
||||
description = 'Rewrites the committed GraphQL public API surface baseline after review.'
|
||||
|
||||
doLast {
|
||||
if (!project.hasProperty('approveGraphQlApiSurfaceChange')) {
|
||||
throw new GradleException(
|
||||
'updateGraphQlApiSurface requires -PapproveGraphQlApiSurfaceChange: growing the ' +
|
||||
'public surface is a review decision, not a build step.')
|
||||
}
|
||||
graphQlApiSurfaceFile.parentFile.mkdirs()
|
||||
graphQlApiSurfaceFile.setText(renderGraphQlApiSurface(), 'UTF-8')
|
||||
logger.lifecycle("updateGraphQlApiSurface: wrote ${graphQlApiSurfaceFile}")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('verifyGraphQlApiSurface')
|
||||
apiSurface {
|
||||
label = 'GraphQl'
|
||||
baseline = rootProject.file('../docs/architecture/graphql-api-surface.txt')
|
||||
description = 'GraphQL leaf public API surface — every public top-level type in src/main/java.'
|
||||
rationale = [
|
||||
'A public type in a single-jar leaf is reachable from every adopter\'s code, so',
|
||||
'additions are reviewed rather than discovered. `api` and `spi` are the intended',
|
||||
'external surface; the rest are candidates to become internal when this leaf is',
|
||||
'split into capability artifacts.',
|
||||
]
|
||||
}
|
||||
|
||||
+11
-29
@@ -1,9 +1,6 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation;
|
||||
|
||||
/**
|
||||
* Propagates cancellation from the client to the source.
|
||||
@@ -12,47 +9,32 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
* consumer, the polling task and the nested publishers all continue for a subscriber that has gone,
|
||||
* and nothing in the request path notices.
|
||||
*
|
||||
* <p>Every hook runs exactly once, and one that throws does not stop the rest. The loop used to
|
||||
* abandon the queue at the first failure, so a broken consumer-close left the polling task and the
|
||||
* nested publishers running — the leak the second and third hooks existed to prevent, caused by the
|
||||
* first one failing.
|
||||
* <p>The signal itself is {@link GraphQlCancellation}, not a second copy of it. There used to be
|
||||
* two one-way cancellation state machines in this platform with the same queue, the same flag and
|
||||
* the same late-registration rule, which is two places for the run-every-hook guarantee to be
|
||||
* correct in — and it was correct in one of them. Whatever the request path guarantees, a
|
||||
* subscription now guarantees by construction: every hook runs exactly once, one that throws does
|
||||
* not stop the rest, and the first failure carries the later ones as suppressed.
|
||||
*/
|
||||
public final class GraphQlSubscriptionCancellation {
|
||||
|
||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||
private final Queue<Runnable> upstream = new ConcurrentLinkedQueue<>();
|
||||
private final GraphQlCancellation cancellation = GraphQlCancellation.create();
|
||||
|
||||
/** Registers upstream work to stop on cancellation. */
|
||||
public void onCancel(Runnable stopUpstream) {
|
||||
if (stopUpstream == null) {
|
||||
throw new IllegalArgumentException("upstream cancellation hook is required");
|
||||
}
|
||||
upstream.add(stopUpstream);
|
||||
if (cancelled.get()) {
|
||||
drain();
|
||||
}
|
||||
cancellation.onCancel(stopUpstream);
|
||||
}
|
||||
|
||||
/** Cancels the subscription and everything upstream of it. */
|
||||
public void cancel() {
|
||||
if (cancelled.compareAndSet(false, true)) {
|
||||
drain();
|
||||
}
|
||||
cancellation.cancel();
|
||||
}
|
||||
|
||||
/** Whether the subscription has been cancelled. */
|
||||
public boolean cancelled() {
|
||||
return cancelled.get();
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
GraphQlContextCleanup cleanup = GraphQlContextCleanup.create();
|
||||
Runnable hook = upstream.poll();
|
||||
while (hook != null) {
|
||||
cleanup.register(hook);
|
||||
hook = upstream.poll();
|
||||
}
|
||||
// The same run-all-then-rethrow-with-suppressed semantics the request path already uses.
|
||||
cleanup.close();
|
||||
return cancellation.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -32,6 +32,7 @@ import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumenta
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter;
|
||||
import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer;
|
||||
@@ -60,6 +61,7 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
|
||||
import org.springframework.graphql.execution.GraphQlSource;
|
||||
import org.springframework.graphql.observation.ExecutionRequestObservationConvention;
|
||||
import org.springframework.graphql.server.WebGraphQlInterceptor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.util.ClassUtils;
|
||||
@@ -526,6 +528,23 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
return new GraphQlRequestObservationConvention(filter, operationNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the bounded request tags to Spring for GraphQL's observation instrumentation.
|
||||
*
|
||||
* <p>The convention above is a value; this is the bean Boot's {@code
|
||||
* GraphQlObservationAutoConfiguration} looks for. Without it the framework falls back to its own
|
||||
* convention and the platform's cardinality policy applies to nothing that is exported — the
|
||||
* shape of defect where a control passes its tests and no request reaches it.
|
||||
*
|
||||
* @param convention the platform's bounded tag policy
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(ExecutionRequestObservationConvention.class)
|
||||
public GraphQlRequestObservationConventionAdapter graphQlExecutionRequestObservationConvention(
|
||||
GraphQlRequestObservationConvention convention) {
|
||||
return new GraphQlRequestObservationConventionAdapter(convention);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which operation names may become metric labels.
|
||||
*
|
||||
@@ -601,6 +620,78 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
builder.configureGraphQl(graphQl -> graphQl.preparsedDocumentProvider(adapter));
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch policies an adopter registers, empty until one does.
|
||||
*
|
||||
* <p>A bean rather than something each adopter constructs, because the two things downstream of
|
||||
* it — the loader factory and the registrar — were beans nowhere, and a platform whose N+1
|
||||
* protection has to be assembled by hand is a platform whose N+1 protection is not applied.
|
||||
*
|
||||
* @return the registry
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(
|
||||
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry.class)
|
||||
public dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry
|
||||
graphQlBatchPolicyRegistry() {
|
||||
return new dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the per-loader batch policy and executor.
|
||||
*
|
||||
* @param policies the registered policies
|
||||
* @param properties the platform settings, which supply the batch ceiling
|
||||
* @param graphQlPlatformClock the clock deadlines are measured against
|
||||
* @return the factory
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(
|
||||
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory.class)
|
||||
public dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory
|
||||
graphQlDataLoaderFactory(
|
||||
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry policies,
|
||||
GraphQlPlatformSettings properties,
|
||||
Clock graphQlPlatformClock) {
|
||||
// The page ceiling is the batch ceiling. Both answer the same question — how many rows one
|
||||
// downstream call may ask for — and a batch limit larger than the page limit would let a single
|
||||
// request fan out past the bound the same request already accepted for its page.
|
||||
return new dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory(
|
||||
policies, properties.limits().maximumPageSize(), graphQlPlatformClock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an adopter's batch loader in the platform's chunking, budget and request scope.
|
||||
*
|
||||
* <p>This was the missing link. {@code GraphQlBatchLoaderRegistrar} existed, was tested, and was
|
||||
* declared by no configuration — so a field resolving through {@code @BatchMapping} or a {@code
|
||||
* DataLoader} met none of the platform's batch policy. The chunking and the budget were a set of
|
||||
* well-tested objects no request could reach.
|
||||
*
|
||||
* <p>An adopter still supplies the downstream call, because only the adopter has one. What it no
|
||||
* longer supplies is the machinery around it.
|
||||
*
|
||||
* @param factory the loader factory
|
||||
* @param blockingBridge the bounded hand-off, when a runtime provides one
|
||||
* @param properties the platform settings, which declare the runtime the loaders will execute on
|
||||
* @return the registrar
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(
|
||||
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar.class)
|
||||
public dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar
|
||||
graphQlBatchLoaderRegistrar(
|
||||
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory factory,
|
||||
ObjectProvider<dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridge>
|
||||
blockingBridge,
|
||||
GraphQlPlatformSettings properties) {
|
||||
// The profile decides where a chunk may run, so the registrar receives it rather than assuming
|
||||
// the servlet answer. A reactive deployment that registers a blocking loader with no bridge is
|
||||
// refused at startup instead of discovering it as event-loop starvation.
|
||||
return new dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar(
|
||||
factory, blockingBridge.getIfAvailable(), properties.executionProfile());
|
||||
}
|
||||
|
||||
private static String schemaContractHash(ObjectProvider<GraphQlSource> graphQlSource) {
|
||||
GraphQlSource source = graphQlSource.getIfAvailable();
|
||||
if (source == null) {
|
||||
|
||||
+10
-1
@@ -20,5 +20,14 @@ import org.springframework.context.annotation.Import;
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true")
|
||||
@EnableConfigurationProperties(GraphQlPlatformSettings.class)
|
||||
@Import(GraphQlPlatformAutoConfiguration.class)
|
||||
@Import({
|
||||
GraphQlPlatformAutoConfiguration.class,
|
||||
// The resolver for the schema's only field. It is a @Controller in a package the composition
|
||||
// root's component scan excludes by regex — the exclusion that makes this capability optional —
|
||||
// and no root imported it, so a deployment with GraphQL on served a schema declaring
|
||||
// `_health: String!` with nothing to resolve it. Every query answered
|
||||
// NullValueInNonNullableField. Its own tests passed throughout by registering the class
|
||||
// themselves, which is the shape of the defect rather than a defence against it.
|
||||
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController.class
|
||||
})
|
||||
public class GraphQlRootAutoConfiguration {}
|
||||
|
||||
+175
-40
@@ -2,18 +2,23 @@ package dev.caskeleton.adapter.inbound.graphql.compat;
|
||||
|
||||
import graphql.language.AstPrinter;
|
||||
import graphql.language.EnumTypeDefinition;
|
||||
import graphql.language.EnumTypeExtensionDefinition;
|
||||
import graphql.language.EnumValueDefinition;
|
||||
import graphql.language.FieldDefinition;
|
||||
import graphql.language.ImplementingTypeDefinition;
|
||||
import graphql.language.InputObjectTypeDefinition;
|
||||
import graphql.language.InputObjectTypeExtensionDefinition;
|
||||
import graphql.language.InputValueDefinition;
|
||||
import graphql.language.InterfaceTypeDefinition;
|
||||
import graphql.language.InterfaceTypeExtensionDefinition;
|
||||
import graphql.language.NonNullType;
|
||||
import graphql.language.ObjectTypeDefinition;
|
||||
import graphql.language.ObjectTypeExtensionDefinition;
|
||||
import graphql.language.ScalarTypeDefinition;
|
||||
import graphql.language.Type;
|
||||
import graphql.language.TypeDefinition;
|
||||
import graphql.language.UnionTypeDefinition;
|
||||
import graphql.language.UnionTypeExtensionDefinition;
|
||||
import graphql.schema.idl.ScalarInfo;
|
||||
import graphql.schema.idl.SchemaParser;
|
||||
import graphql.schema.idl.TypeDefinitionRegistry;
|
||||
@@ -57,26 +62,150 @@ public final class GraphQlSchemaComparator {
|
||||
|
||||
List<GraphQlSchemaChange> changes = new ArrayList<>();
|
||||
|
||||
compareTypePresence(previous, candidate, changes);
|
||||
compareTypeKinds(previous, candidate, changes);
|
||||
compareOutputTypes(previous, candidate, changes);
|
||||
compareInputTypes(previous, candidate, changes);
|
||||
compareEnums(previous, candidate, changes);
|
||||
compareUnions(previous, candidate, changes);
|
||||
// Extensions folded in first. A registry keeps `extend type Query { … }` in a separate map from
|
||||
// `type Query { … }`, so a comparison that reads only the base definitions cannot see a field
|
||||
// an extension contributed — and cannot see it disappear either. Every schema that composes
|
||||
// from several files is exactly this shape, which made the omission a breaking change the gate
|
||||
// reported as no change at all.
|
||||
Map<String, TypeDefinition> previousTypes = withExtensions(previous);
|
||||
Map<String, TypeDefinition> candidateTypes = withExtensions(candidate);
|
||||
|
||||
compareTypePresence(previousTypes, candidateTypes, changes);
|
||||
compareTypeKinds(previousTypes, candidateTypes, changes);
|
||||
compareOutputTypes(previousTypes, candidateTypes, changes);
|
||||
compareInputTypes(previousTypes, candidateTypes, changes);
|
||||
compareEnums(previousTypes, candidateTypes, changes);
|
||||
compareUnions(previousTypes, candidateTypes, changes);
|
||||
compareScalars(previous, candidate, changes);
|
||||
compareDirectives(previous, candidate, changes);
|
||||
compareAppliedDirectives(previous, candidate, changes);
|
||||
compareAppliedDirectives(previousTypes, candidateTypes, changes);
|
||||
|
||||
return new GraphQlCompatibilityReport(changes.stream().sorted(DETERMINISTIC_ORDER).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Every type definition with the members its extensions contribute already merged in.
|
||||
*
|
||||
* <p>The merged form is what a client sees: the engine builds one type out of the base
|
||||
* declaration and every extension of it, and a field's origin is invisible on the wire.
|
||||
*/
|
||||
private static Map<String, TypeDefinition> withExtensions(TypeDefinitionRegistry registry) {
|
||||
Map<String, TypeDefinition> merged = new LinkedHashMap<>();
|
||||
registry.types().forEach((name, type) -> merged.put(name, mergeExtensions(registry, type)));
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static TypeDefinition mergeExtensions(
|
||||
TypeDefinitionRegistry registry, TypeDefinition type) {
|
||||
|
||||
String name = type.getName();
|
||||
if (type instanceof ObjectTypeDefinition object) {
|
||||
List<ObjectTypeExtensionDefinition> extensions =
|
||||
registry.objectTypeExtensions().getOrDefault(name, List.of());
|
||||
if (extensions.isEmpty()) {
|
||||
return object;
|
||||
}
|
||||
List<FieldDefinition> fields = new ArrayList<>(object.getFieldDefinitions());
|
||||
List<Type> interfaces = new ArrayList<>(object.getImplements());
|
||||
List<graphql.language.Directive> directives = new ArrayList<>(object.getDirectives());
|
||||
extensions.forEach(
|
||||
extension -> {
|
||||
fields.addAll(extension.getFieldDefinitions());
|
||||
interfaces.addAll(extension.getImplements());
|
||||
directives.addAll(extension.getDirectives());
|
||||
});
|
||||
return object.transform(
|
||||
builder ->
|
||||
builder.fieldDefinitions(fields).implementz(interfaces).directives(directives));
|
||||
}
|
||||
if (type instanceof InterfaceTypeDefinition definition) {
|
||||
List<InterfaceTypeExtensionDefinition> extensions =
|
||||
registry.interfaceTypeExtensions().getOrDefault(name, List.of());
|
||||
if (extensions.isEmpty()) {
|
||||
return definition;
|
||||
}
|
||||
List<FieldDefinition> fields = new ArrayList<>(definition.getFieldDefinitions());
|
||||
List<Type> interfaces = new ArrayList<>(definition.getImplements());
|
||||
List<graphql.language.Directive> directives = new ArrayList<>(definition.getDirectives());
|
||||
extensions.forEach(
|
||||
extension -> {
|
||||
fields.addAll(extension.getFieldDefinitions());
|
||||
interfaces.addAll(extension.getImplements());
|
||||
directives.addAll(extension.getDirectives());
|
||||
});
|
||||
return definition.transform(
|
||||
builder -> builder.definitions(fields).implementz(interfaces).directives(directives));
|
||||
}
|
||||
if (type instanceof InputObjectTypeDefinition input) {
|
||||
List<InputObjectTypeExtensionDefinition> extensions =
|
||||
registry.inputObjectTypeExtensions().getOrDefault(name, List.of());
|
||||
if (extensions.isEmpty()) {
|
||||
return input;
|
||||
}
|
||||
List<InputValueDefinition> fields = new ArrayList<>(input.getInputValueDefinitions());
|
||||
List<graphql.language.Directive> directives = new ArrayList<>(input.getDirectives());
|
||||
extensions.forEach(
|
||||
extension -> {
|
||||
fields.addAll(extension.getInputValueDefinitions());
|
||||
directives.addAll(extension.getDirectives());
|
||||
});
|
||||
return input.transform(
|
||||
builder -> builder.inputValueDefinitions(fields).directives(directives));
|
||||
}
|
||||
if (type instanceof EnumTypeDefinition enumeration) {
|
||||
List<EnumTypeExtensionDefinition> extensions =
|
||||
registry.enumTypeExtensions().getOrDefault(name, List.of());
|
||||
if (extensions.isEmpty()) {
|
||||
return enumeration;
|
||||
}
|
||||
List<EnumValueDefinition> values = new ArrayList<>(enumeration.getEnumValueDefinitions());
|
||||
List<graphql.language.Directive> directives = new ArrayList<>(enumeration.getDirectives());
|
||||
extensions.forEach(
|
||||
extension -> {
|
||||
values.addAll(extension.getEnumValueDefinitions());
|
||||
directives.addAll(extension.getDirectives());
|
||||
});
|
||||
return enumeration.transform(
|
||||
builder -> builder.enumValueDefinitions(values).directives(directives));
|
||||
}
|
||||
if (type instanceof UnionTypeDefinition union) {
|
||||
List<UnionTypeExtensionDefinition> extensions =
|
||||
registry.unionTypeExtensions().getOrDefault(name, List.of());
|
||||
if (extensions.isEmpty()) {
|
||||
return union;
|
||||
}
|
||||
List<Type> members = new ArrayList<>(union.getMemberTypes());
|
||||
List<graphql.language.Directive> directives = new ArrayList<>(union.getDirectives());
|
||||
extensions.forEach(
|
||||
extension -> {
|
||||
members.addAll(extension.getMemberTypes());
|
||||
directives.addAll(extension.getDirectives());
|
||||
});
|
||||
return union.transform(builder -> builder.memberTypes(members).directives(directives));
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/** The merged definitions of one kind. */
|
||||
private static <T extends TypeDefinition> Map<String, T> typesOf(
|
||||
Map<String, TypeDefinition> types, Class<T> kind) {
|
||||
Map<String, T> selected = new LinkedHashMap<>();
|
||||
types.forEach(
|
||||
(name, type) -> {
|
||||
if (kind.isInstance(type)) {
|
||||
selected.put(name, kind.cast(type));
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static void compareTypePresence(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Set<String> previousTypes = new TreeSet<>(previous.types().keySet());
|
||||
Set<String> candidateTypes = new TreeSet<>(candidate.types().keySet());
|
||||
Set<String> previousTypes = new TreeSet<>(previous.keySet());
|
||||
Set<String> candidateTypes = new TreeSet<>(candidate.keySet());
|
||||
|
||||
previousTypes.stream()
|
||||
.filter(name -> !candidateTypes.contains(name))
|
||||
@@ -94,12 +223,10 @@ public final class GraphQlSchemaComparator {
|
||||
* to another, or as nothing at all.
|
||||
*/
|
||||
private static void compareTypeKinds(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
Map<String, TypeDefinition> previousTypes,
|
||||
Map<String, TypeDefinition> candidateTypes,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, TypeDefinition> previousTypes = previous.types();
|
||||
Map<String, TypeDefinition> candidateTypes = candidate.types();
|
||||
for (String name : new TreeSet<>(previousTypes.keySet())) {
|
||||
TypeDefinition after = candidateTypes.get(name);
|
||||
if (after == null) {
|
||||
@@ -119,12 +246,10 @@ public final class GraphQlSchemaComparator {
|
||||
* definition untouched.
|
||||
*/
|
||||
private static void compareAppliedDirectives(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
Map<String, TypeDefinition> previousTypes,
|
||||
Map<String, TypeDefinition> candidateTypes,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, TypeDefinition> previousTypes = previous.types();
|
||||
Map<String, TypeDefinition> candidateTypes = candidate.types();
|
||||
for (String name : new TreeSet<>(previousTypes.keySet())) {
|
||||
TypeDefinition before = previousTypes.get(name);
|
||||
TypeDefinition after = candidateTypes.get(name);
|
||||
@@ -202,8 +327,8 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareOutputTypes(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, ImplementingTypeDefinition<?>> previousTypes = implementingTypes(previous);
|
||||
@@ -349,14 +474,14 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareInputTypes(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, InputObjectTypeDefinition> previousTypes =
|
||||
previous.getTypesMap(InputObjectTypeDefinition.class);
|
||||
typesOf(previous, InputObjectTypeDefinition.class);
|
||||
Map<String, InputObjectTypeDefinition> candidateTypes =
|
||||
candidate.getTypesMap(InputObjectTypeDefinition.class);
|
||||
typesOf(candidate, InputObjectTypeDefinition.class);
|
||||
|
||||
for (String typeName : new TreeSet<>(previousTypes.keySet())) {
|
||||
InputObjectTypeDefinition after = candidateTypes.get(typeName);
|
||||
@@ -426,13 +551,12 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareEnums(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, EnumTypeDefinition> previousTypes = previous.getTypesMap(EnumTypeDefinition.class);
|
||||
Map<String, EnumTypeDefinition> candidateTypes =
|
||||
candidate.getTypesMap(EnumTypeDefinition.class);
|
||||
Map<String, EnumTypeDefinition> previousTypes = typesOf(previous, EnumTypeDefinition.class);
|
||||
Map<String, EnumTypeDefinition> candidateTypes = typesOf(candidate, EnumTypeDefinition.class);
|
||||
|
||||
for (String typeName : new TreeSet<>(previousTypes.keySet())) {
|
||||
EnumTypeDefinition after = candidateTypes.get(typeName);
|
||||
@@ -462,14 +586,12 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareUnions(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, UnionTypeDefinition> previousTypes =
|
||||
previous.getTypesMap(UnionTypeDefinition.class);
|
||||
Map<String, UnionTypeDefinition> candidateTypes =
|
||||
candidate.getTypesMap(UnionTypeDefinition.class);
|
||||
Map<String, UnionTypeDefinition> previousTypes = typesOf(previous, UnionTypeDefinition.class);
|
||||
Map<String, UnionTypeDefinition> candidateTypes = typesOf(candidate, UnionTypeDefinition.class);
|
||||
|
||||
for (String typeName : new TreeSet<>(previousTypes.keySet())) {
|
||||
UnionTypeDefinition after = candidateTypes.get(typeName);
|
||||
@@ -512,7 +634,7 @@ public final class GraphQlSchemaComparator {
|
||||
changes.add(GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_REMOVED));
|
||||
continue;
|
||||
}
|
||||
if (!print(previousScalars.get(name)).equals(print(after))) {
|
||||
if (!declaration(previousScalars.get(name)).equals(declaration(after))) {
|
||||
changes.add(
|
||||
GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_DECLARATION_CHANGED));
|
||||
}
|
||||
@@ -555,13 +677,26 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static Map<String, ImplementingTypeDefinition<?>> implementingTypes(
|
||||
TypeDefinitionRegistry registry) {
|
||||
Map<String, TypeDefinition> merged) {
|
||||
Map<String, ImplementingTypeDefinition<?>> types = new LinkedHashMap<>();
|
||||
registry.getTypesMap(ObjectTypeDefinition.class).forEach(types::put);
|
||||
registry.getTypesMap(InterfaceTypeDefinition.class).forEach(types::put);
|
||||
typesOf(merged, ObjectTypeDefinition.class).forEach(types::put);
|
||||
typesOf(merged, InterfaceTypeDefinition.class).forEach(types::put);
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scalar's declaration without its description.
|
||||
*
|
||||
* <p>The declaration is compared because it is the only signal SDL carries about how a scalar
|
||||
* coerces — the {@code Coercing} implementation behind it is Java, and swapping it changes the
|
||||
* wire contract without changing a character of schema. Prose is not that signal: reporting a
|
||||
* reworded sentence as a possible coercion change is how a review that matters gets approved
|
||||
* without being read.
|
||||
*/
|
||||
private static String declaration(ScalarTypeDefinition scalar) {
|
||||
return print(scalar.transform(builder -> builder.description(null)));
|
||||
}
|
||||
|
||||
private static Map<String, ScalarTypeDefinition> customScalars(TypeDefinitionRegistry registry) {
|
||||
return registry.scalars().entrySet().stream()
|
||||
.filter(entry -> !ScalarInfo.isGraphqlSpecifiedScalar(entry.getKey()))
|
||||
|
||||
+14
-7
@@ -10,9 +10,9 @@ import java.util.HexFormat;
|
||||
*
|
||||
* <p>The platform needs an actor identity for authorization, idempotency scoping and audit, but the
|
||||
* error contract and the observability contract both forbid a raw user identifier from reaching a
|
||||
* response or a metric label. So the context carries this reference and exposes {@link
|
||||
* #fingerprint()} for anything that must be shared outward, and it never carries an access token,
|
||||
* cookie or raw provider claim.
|
||||
* response or a metric label. So the context carries this reference and never carries an access
|
||||
* token, cookie or raw provider claim; what may be shared outward is a keyed fingerprint from
|
||||
* {@link GraphQlIdentityFingerprinter}.
|
||||
*
|
||||
* @param value opaque, stable caller reference supplied by the authentication adapter
|
||||
* @param authenticated whether a credential was actually verified
|
||||
@@ -49,12 +49,19 @@ public record ActorRef(String value, boolean authenticated) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable, non-reversible fingerprint of this actor.
|
||||
* A stable partition token for this actor, for use inside one running request.
|
||||
*
|
||||
* <p>Safe to use where the raw reference must not appear — idempotency scoping, audit correlation
|
||||
* and subscription principals.
|
||||
* <p>Deliberately not called a fingerprint. An actor reference is low-entropy — a numeric user
|
||||
* id, a service account name — and a digest of a guessable input is recovered by digesting the
|
||||
* guesses, so this hides the reference from a casual reader and from nobody else. It is a
|
||||
* separator for in-memory structures such as the request-scoped DataLoader cache, where the only
|
||||
* requirement is that two actors never share one bucket.
|
||||
*
|
||||
* <p>Anything durable or outward-facing — an idempotency record, an audit trail, a value handed
|
||||
* to another system — uses {@link GraphQlIdentityFingerprinter} instead, which is keyed and can
|
||||
* rotate.
|
||||
*/
|
||||
public String fingerprint() {
|
||||
public String cachePartition() {
|
||||
return sha256Prefix(value);
|
||||
}
|
||||
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.context;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Turns an actor or tenant identity into a value that may be stored outside this process.
|
||||
*
|
||||
* <p>A plain digest is not that value. An actor reference and a tenant name are low-entropy: they
|
||||
* come from a bounded set a reader can enumerate — {@code tenant-a}, {@code acme}, a numeric user
|
||||
* id, an email address — and a digest of a guessable input is recovered by digesting the guesses.
|
||||
* An idempotency record keyed on such a digest therefore still names the caller to anyone holding
|
||||
* the store, which is the one thing hashing it was meant to prevent. A keyed MAC removes the
|
||||
* dictionary attack, because the attacker cannot compute the candidate values without the key.
|
||||
*
|
||||
* <p>Keys are addressed by identity and the identity travels with the fingerprint, so a deployment
|
||||
* can rotate. Rotation matters here more than for a signature: a fingerprint is durable, it sits in
|
||||
* stored idempotency records for as long as they are retained, and a key that can never change is a
|
||||
* key that is compromised permanently. Records written under a retired key stay readable because
|
||||
* their key identity still resolves; new ones are written under the active key.
|
||||
*
|
||||
* <p>The platform supplies no key and no default. A default key is public knowledge, and a
|
||||
* fingerprint under a public key is a plain digest wearing a MAC's name — so the deployment's
|
||||
* secret source is the only way to build this, and a mutation cannot derive an idempotency scope
|
||||
* without one.
|
||||
*
|
||||
* <p>These values are not metric labels. A fingerprint is per actor and per tenant by construction,
|
||||
* which is exactly the unbounded cardinality the observability contract refuses; {@link
|
||||
* dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter} is what
|
||||
* decides what a metric may carry.
|
||||
*/
|
||||
public final class GraphQlIdentityFingerprinter {
|
||||
|
||||
/** Shortest key accepted, matching the block size a truncated key would be padded to anyway. */
|
||||
public static final int MINIMUM_KEY_BYTES = 16;
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
|
||||
/**
|
||||
* Domain tags, so the same string used as an actor and as a tenant does not fingerprint the same.
|
||||
*
|
||||
* <p>Without them a service account named {@code acme} and the tenant named {@code acme} share a
|
||||
* fingerprint, and an idempotency scope built from both halves collapses to one repeated value.
|
||||
*/
|
||||
private static final String ACTOR_DOMAIN = "actor";
|
||||
|
||||
private static final String TENANT_DOMAIN = "tenant";
|
||||
|
||||
private final Map<String, byte[]> keys;
|
||||
private final String activeKeyId;
|
||||
|
||||
private GraphQlIdentityFingerprinter(Map<String, byte[]> keys, String activeKeyId) {
|
||||
this.keys = keys;
|
||||
this.activeKeyId = activeKeyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fingerprinter over a rotating key ring.
|
||||
*
|
||||
* @param keys secrets by key identity, from the deployment's secret source
|
||||
* @param activeKeyId the key new fingerprints are computed under
|
||||
*/
|
||||
public static GraphQlIdentityFingerprinter of(Map<String, byte[]> keys, String activeKeyId) {
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
throw new IllegalArgumentException("identity fingerprint key ring cannot be empty");
|
||||
}
|
||||
if (activeKeyId == null || activeKeyId.isBlank()) {
|
||||
throw new IllegalArgumentException("an active identity fingerprint key is required");
|
||||
}
|
||||
if (!keys.containsKey(activeKeyId)) {
|
||||
throw new IllegalArgumentException("active identity fingerprint key is not in the key ring");
|
||||
}
|
||||
Map<String, byte[]> copy = new LinkedHashMap<>();
|
||||
keys.forEach(
|
||||
(keyId, secret) -> {
|
||||
if (keyId == null || keyId.isBlank() || keyId.indexOf(':') >= 0) {
|
||||
// The key identity is the prefix of every fingerprint it produces, so a colon in it
|
||||
// would make the prefix ambiguous and two rings could mint the same fingerprint text.
|
||||
throw new IllegalArgumentException(
|
||||
"identity fingerprint key id is required and opaque");
|
||||
}
|
||||
if (secret == null || secret.length < MINIMUM_KEY_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"identity fingerprint key " + keyId + " is too short");
|
||||
}
|
||||
copy.put(keyId, secret.clone());
|
||||
});
|
||||
return new GraphQlIdentityFingerprinter(copy, activeKeyId);
|
||||
}
|
||||
|
||||
/** A single-key ring, for a deployment that has not rotated yet. */
|
||||
public static GraphQlIdentityFingerprinter single(String keyId, byte[] secret) {
|
||||
return of(Map.of(keyId, secret), keyId);
|
||||
}
|
||||
|
||||
/** The key new fingerprints are computed under. */
|
||||
public String activeKeyId() {
|
||||
return activeKeyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key identities a stored fingerprint may still be attributed to.
|
||||
*
|
||||
* <p>A copy, so handing the set out cannot retire a key by removing it from the live view.
|
||||
*/
|
||||
public Set<String> keyIds() {
|
||||
return Set.copyOf(keys.keySet());
|
||||
}
|
||||
|
||||
/** The fingerprint of an actor, safe to store alongside an idempotency record. */
|
||||
public String actor(ActorRef actor) {
|
||||
if (actor == null) {
|
||||
throw new IllegalArgumentException("actor is required");
|
||||
}
|
||||
return fingerprint(ACTOR_DOMAIN, actor.value());
|
||||
}
|
||||
|
||||
/** The fingerprint of a tenant, safe to store alongside an idempotency record. */
|
||||
public String tenant(TenantContext tenant) {
|
||||
if (tenant == null) {
|
||||
throw new IllegalArgumentException("tenant is required");
|
||||
}
|
||||
return fingerprint(TENANT_DOMAIN, tenant.value());
|
||||
}
|
||||
|
||||
private String fingerprint(String domain, String value) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM);
|
||||
mac.init(new SecretKeySpec(keys.get(activeKeyId), ALGORITHM));
|
||||
// Length-framed, for the reason the canonical mutation input is: concatenating a domain and a
|
||||
// caller-influenced value lets one of them absorb the other's boundary, and two different
|
||||
// identities then produce one fingerprint.
|
||||
byte[] digest =
|
||||
mac.doFinal(
|
||||
(domain.length() + ":" + domain + "|" + value.length() + ":" + value + "|")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
return activeKeyId + ":" + HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException unavailable) {
|
||||
throw new IllegalStateException(
|
||||
"HMAC-SHA256 is required for identity fingerprints", unavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -51,12 +51,14 @@ public record TenantContext(String value, TenantSource source) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable, non-reversible fingerprint of this tenant.
|
||||
* A stable partition token for this tenant, for use inside one running request.
|
||||
*
|
||||
* <p>The observability contract forbids a raw tenant identifier as a metric label; this is what
|
||||
* goes outward instead.
|
||||
* <p>A tenant name comes from a set small enough to enumerate, so a digest of it is recovered by
|
||||
* digesting the candidates. That is acceptable for what this is used for — keeping one tenant's
|
||||
* request-scoped cache entries out of another's — and not acceptable for anything stored or
|
||||
* shared, which uses the keyed {@link GraphQlIdentityFingerprinter} instead.
|
||||
*/
|
||||
public String fingerprint() {
|
||||
public String cachePartition() {
|
||||
return ActorRef.sha256Prefix(value);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -20,9 +20,18 @@ import java.util.function.BiFunction;
|
||||
* more downstream call and wait for it however long it took, which is the case the budget exists
|
||||
* for. Bounding the call itself is the loader's job, and the deadline is handed to it for that;
|
||||
* this check is what stops the batch continuing past a budget that has already gone.
|
||||
*
|
||||
* <p>Every chunk's answer goes through {@link GraphQlBatchResultMapper} before it is believed. The
|
||||
* mapper is where "the loader answered a question nobody asked" and "this key has no row" are
|
||||
* distinguished, and while the executor handed the loader's map straight back, neither distinction
|
||||
* reached a request: a result under the wrong keys rendered every parent's child as null, and a
|
||||
* loader declared as always resolving reported its absences as legitimate nulls. Both are the shape
|
||||
* that produces plausible data instead of an error.
|
||||
*/
|
||||
public final class GraphQlBatchExecutor {
|
||||
|
||||
private static final GraphQlBatchResultMapper MAPPER = new GraphQlBatchResultMapper();
|
||||
|
||||
private final GraphQlBatchPolicy policy;
|
||||
private final GraphQlBatchChunker chunker;
|
||||
private final Clock clock;
|
||||
@@ -61,7 +70,24 @@ public final class GraphQlBatchExecutor {
|
||||
|
||||
for (List<K> chunk : chunker.chunk(keys)) {
|
||||
requireBudget(started, budget);
|
||||
loaded.putAll(loadChunk.apply(chunk, context));
|
||||
Map<K, V> answered = loadChunk.apply(chunk, context);
|
||||
if (answered == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"loader " + policy.loaderName().value() + " returned no result for a chunk");
|
||||
}
|
||||
// One outcome per requested key, so a missing row and a wrong-key answer are two different
|
||||
// facts. A key whose policy is NULL_VALUE is simply absent from the returned map, which is
|
||||
// how a mapped DataLoader spells "no value" without claiming one.
|
||||
MAPPER
|
||||
.map(chunk, answered)
|
||||
.values()
|
||||
.forEach(
|
||||
(key, value) -> {
|
||||
V resolved = MAPPER.resolve(value, policy.missingKeyPolicy());
|
||||
if (resolved != null) {
|
||||
loaded.put(key, resolved);
|
||||
}
|
||||
});
|
||||
// After, too: a chunk that overran the budget must not have its result used and must not be
|
||||
// followed by another one.
|
||||
requireBudget(started, budget);
|
||||
|
||||
+20
-1
@@ -14,6 +14,13 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
* <p>Cancellation is one-way, and each listener runs exactly once — listeners are drained from a
|
||||
* queue rather than iterated, so a listener registered concurrently with cancellation is neither
|
||||
* dropped nor run twice.
|
||||
*
|
||||
* <p>A listener that throws does not stop the rest. The drain used to abandon the queue at the
|
||||
* first failure, which meant a downstream client that refused to close left the statement and the
|
||||
* publisher behind it running — the leaks the later listeners existed to prevent, caused by the
|
||||
* first one failing and hidden behind the exception that stopped it. Every listener is now
|
||||
* attempted; the first failure is rethrown once the queue is empty, with the later ones attached to
|
||||
* it as suppressed, so a second broken listener is not invisible until the first is fixed.
|
||||
*/
|
||||
public final class GraphQlCancellation {
|
||||
|
||||
@@ -65,10 +72,22 @@ public final class GraphQlCancellation {
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
RuntimeException firstFailure = null;
|
||||
Runnable listener = listeners.poll();
|
||||
while (listener != null) {
|
||||
listener.run();
|
||||
try {
|
||||
listener.run();
|
||||
} catch (RuntimeException failure) {
|
||||
if (firstFailure == null) {
|
||||
firstFailure = failure;
|
||||
} else {
|
||||
firstFailure.addSuppressed(failure);
|
||||
}
|
||||
}
|
||||
listener = listeners.poll();
|
||||
}
|
||||
if (firstFailure != null) {
|
||||
throw firstFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -83,6 +83,7 @@ public enum GraphQlAdvancedModule {
|
||||
"advanced.subscription",
|
||||
"advanced.security",
|
||||
"api",
|
||||
"execution",
|
||||
"http",
|
||||
"security"),
|
||||
|
||||
|
||||
+1
@@ -118,6 +118,7 @@ public enum GraphQlStableModule {
|
||||
"error",
|
||||
"execution",
|
||||
"http",
|
||||
"observation",
|
||||
"policy",
|
||||
"security"),
|
||||
|
||||
|
||||
+6
-6
@@ -17,8 +17,8 @@ package dev.caskeleton.adapter.inbound.graphql.mutation;
|
||||
* implement replay, record storage or locking: those need transactional guarantees the transport
|
||||
* layer cannot give.
|
||||
*
|
||||
* @param actorFingerprint non-reversible actor identity
|
||||
* @param tenantFingerprint non-reversible tenant identity
|
||||
* @param actorFingerprint keyed actor fingerprint, from the deployment's identity fingerprinter
|
||||
* @param tenantFingerprint keyed tenant fingerprint, from the same fingerprinter
|
||||
* @param coordinate the mutation the key belongs to
|
||||
* @param contractVersion the mutation contract version the key was issued under
|
||||
* @param key the client-supplied key
|
||||
@@ -74,10 +74,10 @@ public record GraphQlMutationIdempotencyContext(
|
||||
/**
|
||||
* The storage scope for the Application's idempotency record.
|
||||
*
|
||||
* <p>Uses fingerprints rather than the raw actor and tenant, so the scope can be persisted and
|
||||
* logged. Length-framed for the same reason the canonical input form is: joining five
|
||||
* caller-influenced values with a separator lets one of them contain the separator and collide
|
||||
* with a different scope.
|
||||
* <p>Uses the keyed fingerprints rather than the raw actor and tenant, so the scope can be
|
||||
* persisted and logged without naming the caller to whoever holds the store. Length-framed for
|
||||
* the same reason the canonical input form is: joining five caller-influenced values with a
|
||||
* separator lets one of them contain the separator and collide with a different scope.
|
||||
*/
|
||||
public String scope() {
|
||||
StringBuilder scope = new StringBuilder();
|
||||
|
||||
+11
-2
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.mutation;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy;
|
||||
import java.util.Map;
|
||||
@@ -23,6 +24,7 @@ public final class GraphQlMutationIdempotencyInterceptor {
|
||||
* Derives the idempotency scope for one mutation.
|
||||
*
|
||||
* @param context the request context, whose actor scopes the key
|
||||
* @param fingerprinter the deployment's keyed identity fingerprinter
|
||||
* @param coordinate the mutation being executed
|
||||
* @param extensions request extensions, which may carry the key
|
||||
* @param normalizedInput the mutation's normalised business input
|
||||
@@ -30,11 +32,18 @@ public final class GraphQlMutationIdempotencyInterceptor {
|
||||
*/
|
||||
public static Optional<GraphQlMutationIdempotencyContext> from(
|
||||
GraphQlRequestContext context,
|
||||
GraphQlIdentityFingerprinter fingerprinter,
|
||||
GraphQlMutationCoordinate coordinate,
|
||||
String contractVersion,
|
||||
Map<String, Object> extensions,
|
||||
Map<String, ?> normalizedInput) {
|
||||
|
||||
if (fingerprinter == null) {
|
||||
// No key, no scope. Falling back to a plain digest here would put a recoverable actor and
|
||||
// tenant into a record the Application persists, which is the failure the keyed fingerprint
|
||||
// exists to stop — and it would do it silently, on a deployment that never configured a key.
|
||||
throw new IllegalArgumentException("an identity fingerprinter is required");
|
||||
}
|
||||
Object supplied =
|
||||
extensions == null ? null : extensions.get(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY);
|
||||
if (supplied == null) {
|
||||
@@ -45,8 +54,8 @@ public final class GraphQlMutationIdempotencyInterceptor {
|
||||
}
|
||||
return Optional.of(
|
||||
GraphQlMutationIdempotencyContext.of(
|
||||
context.actor().fingerprint(),
|
||||
context.tenant().fingerprint(),
|
||||
fingerprinter.actor(context.actor()),
|
||||
fingerprinter.tenant(context.tenant()),
|
||||
coordinate,
|
||||
contractVersion,
|
||||
new GraphQlIdempotencyKey(key),
|
||||
|
||||
+20
@@ -57,6 +57,26 @@ public final class GraphQlOperationNameCardinality {
|
||||
return registered.contains(operationName.value()) ? operationName.value() : UNREGISTERED;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounded label for one name as it arrived on the wire.
|
||||
*
|
||||
* <p>The wire carries a string, and it is a wider string than {@link GraphQlOperationName}
|
||||
* accepts: graphql-java admits any GraphQL {@code Name}, so {@code ab} and {@code _internal}
|
||||
* reach execution and would make the value type throw. A policy that can only be asked about
|
||||
* names it already considers well formed is not a bound on what a client can send, so the
|
||||
* question is answered against the raw string — and a raw string becomes a label only by being
|
||||
* exactly one the deployment declared.
|
||||
*
|
||||
* @param wireOperationName the client-supplied name, or {@code null} for an anonymous operation
|
||||
* @return the registered name, the anonymous value, or {@link #UNREGISTERED}
|
||||
*/
|
||||
public String labelForWireName(String wireOperationName) {
|
||||
if (wireOperationName == null || wireOperationName.isBlank()) {
|
||||
return GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE;
|
||||
}
|
||||
return registered.contains(wireOperationName) ? wireOperationName : UNREGISTERED;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of distinct labels this policy can ever produce.
|
||||
*
|
||||
|
||||
+60
-1
@@ -89,8 +89,67 @@ public final class GraphQlRequestObservationConvention {
|
||||
GraphQlComplexityResult complexity,
|
||||
int depth) {
|
||||
|
||||
return tags(
|
||||
operationNames.labelFor(operationName),
|
||||
operationType,
|
||||
clientProfile,
|
||||
persisted,
|
||||
outcome,
|
||||
errorCategory,
|
||||
complexity,
|
||||
depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the request tags from the operation name as it arrived on the wire.
|
||||
*
|
||||
* <p>The overload exists for the runtime seam, which sees the raw request rather than a validated
|
||||
* name. Routing it through the same cardinality policy is what keeps one bound instead of two: a
|
||||
* second place that decides which names become labels is a second place for the bound to be
|
||||
* missing.
|
||||
*
|
||||
* @param wireOperationName client-supplied operation name, or {@code null} when anonymous
|
||||
* @param operationType root operation type
|
||||
* @param clientProfile bounded client profile
|
||||
* @param persisted whether the operation came from the persisted registry
|
||||
* @param outcome bounded outcome name
|
||||
* @param errorCategory bounded error category, or {@code null}
|
||||
* @param complexity computed complexity, or {@code null} when the request never reached costing
|
||||
* @param depth measured selection depth
|
||||
*/
|
||||
public Map<String, String> tagsForWireName(
|
||||
String wireOperationName,
|
||||
GraphQlOperationType operationType,
|
||||
GraphQlClientProfile clientProfile,
|
||||
boolean persisted,
|
||||
String outcome,
|
||||
String errorCategory,
|
||||
GraphQlComplexityResult complexity,
|
||||
int depth) {
|
||||
|
||||
return tags(
|
||||
operationNames.labelForWireName(wireOperationName),
|
||||
operationType,
|
||||
clientProfile,
|
||||
persisted,
|
||||
outcome,
|
||||
errorCategory,
|
||||
complexity,
|
||||
depth);
|
||||
}
|
||||
|
||||
private Map<String, String> tags(
|
||||
String operationNameLabel,
|
||||
GraphQlOperationType operationType,
|
||||
GraphQlClientProfile clientProfile,
|
||||
boolean persisted,
|
||||
String outcome,
|
||||
String errorCategory,
|
||||
GraphQlComplexityResult complexity,
|
||||
int depth) {
|
||||
|
||||
Map<String, String> tags = new LinkedHashMap<>();
|
||||
tags.put("graphql.operation.name", operationNames.labelFor(operationName));
|
||||
tags.put("graphql.operation.name", operationNameLabel);
|
||||
tags.put("graphql.operation.type", operationType.name());
|
||||
tags.put("graphql.client.profile", clientProfile.value());
|
||||
tags.put("graphql.persisted", Boolean.toString(persisted));
|
||||
|
||||
+26
-3
@@ -6,6 +6,8 @@ import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -24,11 +26,20 @@ import org.springframework.graphql.execution.BatchLoaderRegistry;
|
||||
* <p>The chunking, budget and scope arrive as a decorator around the adopter's loader rather than
|
||||
* as something the adopter has to remember. What the adopter supplies is the downstream call; what
|
||||
* this adds is everything that makes it safe to run on a shared request budget.
|
||||
*
|
||||
* <p>Where the chunk actually runs is a property of the runtime, so the registrar is told which one
|
||||
* it is. On a servlet stack the answer is "here": Spring already put the request on a thread, and a
|
||||
* second pool would only add a queue and a wait. On a reactive stack the same inline call runs on
|
||||
* the event loop, where one slow downstream stalls every request the loop is serving — so a
|
||||
* reactive deployment either supplies the bounded bridge or does not get to register a blocking
|
||||
* loader at all. Refusing at registration makes that a startup failure rather than a latency
|
||||
* mystery under load.
|
||||
*/
|
||||
public final class GraphQlBatchLoaderRegistrar {
|
||||
|
||||
private final GraphQlDataLoaderFactory factory;
|
||||
private final GraphQlBlockingBridge blockingBridge;
|
||||
private final GraphQlExecutionProfile profile;
|
||||
|
||||
/**
|
||||
* Creates a registrar that runs loaders on the calling thread.
|
||||
@@ -37,9 +48,11 @@ public final class GraphQlBatchLoaderRegistrar {
|
||||
* a second pool adds a queue, a wait and a context hop, and buys nothing on a servlet stack.
|
||||
*
|
||||
* @param factory supplies the per-loader batch policy and executor
|
||||
* @param profile the runtime this deployment declared
|
||||
*/
|
||||
public GraphQlBatchLoaderRegistrar(GraphQlDataLoaderFactory factory) {
|
||||
this(factory, null);
|
||||
public GraphQlBatchLoaderRegistrar(
|
||||
GraphQlDataLoaderFactory factory, GraphQlExecutionProfile profile) {
|
||||
this(factory, null, profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,11 +60,15 @@ public final class GraphQlBatchLoaderRegistrar {
|
||||
*
|
||||
* @param factory supplies the per-loader batch policy and executor
|
||||
* @param blockingBridge the bounded hand-off, for a runtime where blocking in place is unsafe
|
||||
* @param profile the runtime this deployment declared
|
||||
*/
|
||||
public GraphQlBatchLoaderRegistrar(
|
||||
GraphQlDataLoaderFactory factory, GraphQlBlockingBridge blockingBridge) {
|
||||
GraphQlDataLoaderFactory factory,
|
||||
GraphQlBlockingBridge blockingBridge,
|
||||
GraphQlExecutionProfile profile) {
|
||||
this.factory = Objects.requireNonNull(factory, "data loader factory is required");
|
||||
this.blockingBridge = blockingBridge;
|
||||
this.profile = Objects.requireNonNull(profile, "execution profile is required");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +89,12 @@ public final class GraphQlBatchLoaderRegistrar {
|
||||
GraphQlDataLoaderName loaderName,
|
||||
BiFunction<List<K>, GraphQlBatchContext, Map<K, V>> loadChunk) {
|
||||
|
||||
if (profile != GraphQlExecutionProfile.BLOCKING_MVC && blockingBridge == null) {
|
||||
throw new GraphQlExecutionProfileException(
|
||||
"loader "
|
||||
+ loaderName.value()
|
||||
+ " would block the event loop; declare a bounded blocking bridge");
|
||||
}
|
||||
GraphQlBatchExecutor executor = factory.executorFor(loaderName);
|
||||
|
||||
registry
|
||||
|
||||
+10
@@ -36,6 +36,16 @@ public record GraphQlExecutionContext(
|
||||
GraphQlDocumentShape shape,
|
||||
GraphQlComplexityResult complexity) {
|
||||
|
||||
/**
|
||||
* Key under which the settled pipeline state is published for the rest of the request.
|
||||
*
|
||||
* <p>The measured shape and the scored complexity are computed once, by the cost stage, and were
|
||||
* then discarded when the chain returned. Anything later in the request that wants to describe
|
||||
* how large this request was — an observation convention, a diagnostic — had no way to ask, so it
|
||||
* either re-measured the document or reported a number it had not measured.
|
||||
*/
|
||||
public static final String CONTEXT_KEY = "dev.caskeleton.graphql.executionContext";
|
||||
|
||||
public GraphQlExecutionContext {
|
||||
Objects.requireNonNull(request, "request is required");
|
||||
Objects.requireNonNull(requestContext, "request context is required");
|
||||
|
||||
+3
@@ -95,6 +95,9 @@ public final class GraphQlPlatformInstrumentation extends SimplePerformantInstru
|
||||
execution
|
||||
.getGraphQLContext()
|
||||
.put(GraphQlRequestContext.CONTEXT_KEY, completed.requestContext());
|
||||
// The whole settled state, not only the context: the cost stage has measured this document's
|
||||
// depth and scored its complexity, and those numbers exist nowhere else once the chain returns.
|
||||
execution.getGraphQLContext().put(GraphQlExecutionContext.CONTEXT_KEY, completed);
|
||||
return SimpleInstrumentationContext.noOp();
|
||||
}
|
||||
}
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention;
|
||||
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType;
|
||||
import graphql.ExecutionResult;
|
||||
import graphql.GraphQLError;
|
||||
import graphql.execution.ExecutionContext;
|
||||
import io.micrometer.common.KeyValue;
|
||||
import io.micrometer.common.KeyValues;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.graphql.observation.ExecutionRequestObservationContext;
|
||||
import org.springframework.graphql.observation.ExecutionRequestObservationConvention;
|
||||
|
||||
/**
|
||||
* Makes the platform's bounded request tags the ones a metrics backend actually receives.
|
||||
*
|
||||
* <p>The convention was a well-tested object with no consumer. Spring for GraphQL emits the {@code
|
||||
* graphql.request} observation from its own instrumentation and asks an {@link
|
||||
* ExecutionRequestObservationConvention} bean what to tag it with; the platform's convention did
|
||||
* not implement that interface, so the cardinality bound it computes was never applied to a series
|
||||
* anybody stored. A tag policy that no exporter consults bounds nothing.
|
||||
*
|
||||
* <p>The adapter lives here rather than in the observation package because the bound has to be
|
||||
* framework-free to be testable without a running application, and the seam that applies it has to
|
||||
* speak Spring and graphql-java. So the decision stays a value, and this class translates.
|
||||
*
|
||||
* <p>Everything it reports is bounded by construction. The operation name goes through the
|
||||
* deployment's registry, the type is an enum, the profile is a validated identity, the outcome and
|
||||
* the error category are closed sets, and the size numbers are buckets. The per-request identity
|
||||
* that a debugger needs — the execution id — is reported as a high-cardinality value, which
|
||||
* Micrometer carries on the trace and keeps off the meter.
|
||||
*/
|
||||
public final class GraphQlRequestObservationConventionAdapter
|
||||
implements ExecutionRequestObservationConvention {
|
||||
|
||||
/** Outcome of a request that produced no error. */
|
||||
public static final String OUTCOME_SUCCESS = "SUCCESS";
|
||||
|
||||
/** Outcome of a request the caller could have avoided. */
|
||||
public static final String OUTCOME_REQUEST_ERROR = "REQUEST_ERROR";
|
||||
|
||||
/** Outcome of a request that failed for a reason the caller cannot fix. */
|
||||
public static final String OUTCOME_INTERNAL_ERROR = "INTERNAL_ERROR";
|
||||
|
||||
private static final GraphQlClientProfile ANONYMOUS_PROFILE =
|
||||
new GraphQlClientProfile("anonymous");
|
||||
|
||||
private final GraphQlRequestObservationConvention convention;
|
||||
|
||||
/**
|
||||
* Creates the adapter.
|
||||
*
|
||||
* @param convention the platform's bounded tag policy
|
||||
*/
|
||||
public GraphQlRequestObservationConventionAdapter(
|
||||
GraphQlRequestObservationConvention convention) {
|
||||
this.convention = Objects.requireNonNull(convention, "observation convention is required");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return convention.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContextualName(ExecutionRequestObservationContext context) {
|
||||
// The span name, and therefore as bounded as a tag: the operation type, never the client's
|
||||
// chosen operation name.
|
||||
return "graphql " + operationType(context).name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyValues getLowCardinalityKeyValues(ExecutionRequestObservationContext context) {
|
||||
GraphQlExecutionContext settled = settled(context);
|
||||
Map<String, String> tags =
|
||||
convention.tagsForWireName(
|
||||
context.getExecutionInput().getOperationName(),
|
||||
operationType(context),
|
||||
clientProfile(context),
|
||||
// False rather than unknown: the platform's persisted-operation stage is not on this
|
||||
// execution path, so no request reaching here came out of the persisted registry.
|
||||
false,
|
||||
outcome(context),
|
||||
errorCategory(context),
|
||||
settled == null ? null : settled.complexity(),
|
||||
depth(settled));
|
||||
|
||||
KeyValues keyValues = KeyValues.empty();
|
||||
for (Map.Entry<String, String> tag : tags.entrySet()) {
|
||||
keyValues = keyValues.and(KeyValue.of(tag.getKey(), tag.getValue()));
|
||||
}
|
||||
return keyValues;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyValues getHighCardinalityKeyValues(ExecutionRequestObservationContext context) {
|
||||
Object executionId = context.getExecutionInput().getExecutionId();
|
||||
return executionId == null
|
||||
? KeyValues.empty()
|
||||
: KeyValues.of("graphql.execution.id", executionId.toString());
|
||||
}
|
||||
|
||||
private static GraphQlOperationType operationType(ExecutionRequestObservationContext context) {
|
||||
ExecutionContext execution = context.getExecutionContext();
|
||||
if (execution == null || execution.getOperationDefinition() == null) {
|
||||
// The observation is stopped even when parsing never selected an operation. Reporting the
|
||||
// read type is what keeps the series shape stable across a request that failed early.
|
||||
return GraphQlOperationType.QUERY;
|
||||
}
|
||||
return switch (execution.getOperationDefinition().getOperation()) {
|
||||
case QUERY -> GraphQlOperationType.QUERY;
|
||||
case MUTATION -> GraphQlOperationType.MUTATION;
|
||||
case SUBSCRIPTION -> GraphQlOperationType.SUBSCRIPTION;
|
||||
};
|
||||
}
|
||||
|
||||
private static GraphQlClientProfile clientProfile(ExecutionRequestObservationContext context) {
|
||||
GraphQlRequestContext requestContext =
|
||||
context.getExecutionInput().getGraphQLContext().get(GraphQlRequestContext.CONTEXT_KEY);
|
||||
// Anonymous is a profile, not a missing value: a request that proved nothing is still one the
|
||||
// operator has to be able to count.
|
||||
return requestContext == null ? ANONYMOUS_PROFILE : requestContext.clientProfile();
|
||||
}
|
||||
|
||||
private static GraphQlExecutionContext settled(ExecutionRequestObservationContext context) {
|
||||
return context.getExecutionInput().getGraphQLContext().get(GraphQlExecutionContext.CONTEXT_KEY);
|
||||
}
|
||||
|
||||
private static int depth(GraphQlExecutionContext settled) {
|
||||
return settled == null || settled.shape() == null ? 0 : settled.shape().depth();
|
||||
}
|
||||
|
||||
private static String outcome(ExecutionRequestObservationContext context) {
|
||||
if (context.getError() != null) {
|
||||
return OUTCOME_INTERNAL_ERROR;
|
||||
}
|
||||
ExecutionResult result = context.getExecutionResult();
|
||||
if (result == null) {
|
||||
return OUTCOME_INTERNAL_ERROR;
|
||||
}
|
||||
List<GraphQLError> errors = result.getErrors();
|
||||
if (errors == null || errors.isEmpty()) {
|
||||
return OUTCOME_SUCCESS;
|
||||
}
|
||||
return internal(errors) ? OUTCOME_INTERNAL_ERROR : OUTCOME_REQUEST_ERROR;
|
||||
}
|
||||
|
||||
private static String errorCategory(ExecutionRequestObservationContext context) {
|
||||
if (context.getError() != null) {
|
||||
return GraphQlErrorCategory.INTERNAL.name();
|
||||
}
|
||||
ExecutionResult result = context.getExecutionResult();
|
||||
List<GraphQLError> errors = result == null ? null : result.getErrors();
|
||||
if (errors == null || errors.isEmpty()) {
|
||||
// Absent rather than a "none" label: an outcome tag already says the request succeeded, and a
|
||||
// second tag saying the same thing doubles the series for no extra answer.
|
||||
return null;
|
||||
}
|
||||
return internal(errors)
|
||||
? GraphQlErrorCategory.INTERNAL.name()
|
||||
: GraphQlErrorCategory.REQUEST.name();
|
||||
}
|
||||
|
||||
private static boolean internal(List<GraphQLError> errors) {
|
||||
// A resolver that threw is the server's problem; a document the schema refused is the caller's.
|
||||
// Splitting them is the difference between an alert and a client-side bug report.
|
||||
return errors.stream()
|
||||
.anyMatch(error -> error.getErrorType() == graphql.ErrorType.DataFetchingException);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.security;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.application.security.ObjectAccessDecision;
|
||||
import dev.caskeleton.application.security.ObjectAccessPolicy;
|
||||
import dev.caskeleton.application.security.ObjectAccessRequest;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Asks the application's object-access rule on the platform's behalf.
|
||||
*
|
||||
* <p>The direction is what this class is for. The platform decides <em>when</em> an object needs an
|
||||
* access check; the application decides the answer, because the answer depends on domain state.
|
||||
* Those two facts used to be expressed by a port declared in this leaf whose method took a {@link
|
||||
* GraphQlRequestContext} — a contract the application layer could not implement without depending
|
||||
* on the inbound GraphQL adapter, which the dependency gate forbids and which would have dragged
|
||||
* the transport into every persistence adapter behind the use case.
|
||||
*
|
||||
* <p>So the contract moved to {@code application-core} in terms of plain values, and what stays
|
||||
* here is the mapping: request context in, four strings out, decision back. It is the same shape
|
||||
* the persisted-operation registry uses against a neutral store contract, for the same reason.
|
||||
*/
|
||||
public final class ApplicationObjectAuthorization implements GraphQlObjectAuthorizationPort {
|
||||
|
||||
private final ObjectAccessPolicy policy;
|
||||
|
||||
/**
|
||||
* Creates the bridge.
|
||||
*
|
||||
* @param policy the application's object-access rule
|
||||
*/
|
||||
public ApplicationObjectAuthorization(ObjectAccessPolicy policy) {
|
||||
this.policy = Objects.requireNonNull(policy, "object access policy is required");
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQlAuthorizationDecision authorize(
|
||||
GraphQlRequestContext context, String objectType, String objectId) {
|
||||
|
||||
GraphQlCommandAttribution attribution = GraphQlCommandAttribution.from(context);
|
||||
return map(
|
||||
policy.decide(
|
||||
new ObjectAccessRequest(
|
||||
attribution.actorId(), attribution.tenantId(), objectType, objectId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, GraphQlAuthorizationDecision> authorizeAll(
|
||||
GraphQlRequestContext context, String objectType, List<String> objectIds) {
|
||||
|
||||
GraphQlCommandAttribution attribution = GraphQlCommandAttribution.from(context);
|
||||
Map<String, ObjectAccessDecision> decided =
|
||||
policy.decideAll(
|
||||
attribution.actorId(), attribution.tenantId(), objectType, List.copyOf(objectIds));
|
||||
|
||||
Map<String, GraphQlAuthorizationDecision> mapped = new LinkedHashMap<>();
|
||||
objectIds.forEach(
|
||||
objectId -> {
|
||||
ObjectAccessDecision decision = decided.get(objectId);
|
||||
if (decision == null) {
|
||||
// A missing answer is a denial, never an omission: a caller that dropped ids from its
|
||||
// response would leave the loader with no decision for those objects, and "no decision"
|
||||
// is the one state that must not read as permission.
|
||||
mapped.put(objectId, GraphQlAuthorizationDecision.deny("OBJECT_NOT_AUTHORIZED"));
|
||||
return;
|
||||
}
|
||||
mapped.put(objectId, map(decision));
|
||||
});
|
||||
return Map.copyOf(mapped);
|
||||
}
|
||||
|
||||
private static GraphQlAuthorizationDecision map(ObjectAccessDecision decision) {
|
||||
if (decision.allowed()) {
|
||||
return GraphQlAuthorizationDecision.allow();
|
||||
}
|
||||
return decision.hideExistence()
|
||||
? GraphQlAuthorizationDecision.denyHidingExistence(decision.code())
|
||||
: GraphQlAuthorizationDecision.deny(decision.code());
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -37,9 +37,12 @@ public record GraphQlBatchContext(ActorRef actor, TenantContext tenant, GraphQlD
|
||||
/**
|
||||
* The cache-key prefix that keeps one tenant's loaded values out of another's.
|
||||
*
|
||||
* <p>A fingerprint rather than the raw tenant, so the key is safe if it is ever logged.
|
||||
* <p>Partition tokens rather than the raw identities, so a cache key that reaches a debug log
|
||||
* does not read as a list of who called and for whom. The tokens are not a privacy control — the
|
||||
* identities behind them are guessable — but this key never leaves the request that built it, and
|
||||
* what does leave uses the keyed fingerprinter.
|
||||
*/
|
||||
public String cacheScope() {
|
||||
return actor.fingerprint() + ":" + tenant.fingerprint();
|
||||
return actor.cachePartition() + ":" + tenant.cachePartition();
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -5,12 +5,18 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The Application port that answers "may this caller see this object?".
|
||||
* The platform-side seam for "may this caller see this object?".
|
||||
*
|
||||
* <p>A port rather than a repository call from platform code: whether a caller may see an object
|
||||
* <p>A seam rather than a repository call from platform code: whether a caller may see an object
|
||||
* depends on domain state — ownership, membership, workflow status — which the transport layer has
|
||||
* no business querying. The platform decides <em>when</em> to ask; the Application decides the
|
||||
* answer.
|
||||
* no business querying. The platform decides <em>when</em> to ask.
|
||||
*
|
||||
* <p>It does not decide who answers. The contract that answers is {@code
|
||||
* dev.caskeleton.application.security.ObjectAccessPolicy}, phrased in plain values so the
|
||||
* application layer can own it; {@link ApplicationObjectAuthorization} maps between the two. This
|
||||
* interface used to describe itself as the Application port, which no application code could have
|
||||
* implemented — its method signature named a GraphQL request context, so implementing it required
|
||||
* depending on this transport adapter.
|
||||
*
|
||||
* <p>The batch method exists because object authorization inside a DataLoader would otherwise
|
||||
* reintroduce the N+1 the loader was added to remove.
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
/**
|
||||
* The batch-loader chain is assembled by the platform, not by each adopter.
|
||||
*
|
||||
* <p>{@code GraphQlBatchLoaderRegistrar} carried the chunking, the budget and the request scope,
|
||||
* was unit tested, and was declared by no configuration — the only file in the repository that
|
||||
* mentioned it was itself. A field resolving through {@code @BatchMapping} or a {@code DataLoader}
|
||||
* therefore met none of it: the platform's N+1 protection existed as a set of objects no request
|
||||
* could reach.
|
||||
*
|
||||
* <p>An adopter still supplies the downstream call, because only the adopter has one. What it no
|
||||
* longer supplies is the machinery around it, which is what this asserts.
|
||||
*/
|
||||
class GraphQlBatchLoaderWiringTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL");
|
||||
|
||||
@Test
|
||||
@DisplayName("the platform supplies the whole batch chain when GraphQL is on")
|
||||
void theChainIsSupplied() {
|
||||
runner.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasSingleBean(GraphQlBatchPolicyRegistry.class)
|
||||
.hasSingleBean(GraphQlDataLoaderFactory.class)
|
||||
.hasSingleBean(GraphQlBatchLoaderRegistrar.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an adopter's own registry replaces the platform's, rather than colliding with it")
|
||||
void anAdopterRegistryWins() {
|
||||
runner
|
||||
.withBean(GraphQlBatchPolicyRegistry.class, GraphQlBatchPolicyRegistry::new)
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context).hasNotFailed().hasSingleBean(GraphQlBatchPolicyRegistry.class));
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.graphql.autoconfigure.observation.GraphQlObservationAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.graphql.observation.ExecutionRequestObservationConvention;
|
||||
import org.springframework.graphql.observation.GraphQlObservationInstrumentation;
|
||||
|
||||
/**
|
||||
* The tag policy has to reach the instrumentation that emits the observation.
|
||||
*
|
||||
* <p>Spring for GraphQL resolves one {@link ExecutionRequestObservationConvention} bean and falls
|
||||
* back to its own when it finds none. The platform's cardinality policy used to be a bean of a type
|
||||
* nothing looked for, so the framework took the fallback and the bounded operation-name label was
|
||||
* computed for nobody — a control with tests, no consumer, and no way to tell from the metrics.
|
||||
*/
|
||||
class GraphQlObservationWiringTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(
|
||||
AutoConfigurations.of(
|
||||
GraphQlRootAutoConfiguration.class, GraphQlObservationAutoConfiguration.class))
|
||||
.withBean(ObservationRegistry.class, ObservationRegistry::create)
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL");
|
||||
|
||||
@Test
|
||||
void theFrameworkInstrumentationResolvesThePlatformConvention() {
|
||||
runner.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).hasSingleBean(GraphQlObservationInstrumentation.class);
|
||||
assertThat(context)
|
||||
.getBean(ExecutionRequestObservationConvention.class)
|
||||
.as("the only convention the framework can resolve must be the bounded one")
|
||||
.isInstanceOf(GraphQlRequestObservationConventionAdapter.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAdopterConventionKeepsTheFrameworkFromTakingThePlatformOne() {
|
||||
runner
|
||||
.withBean(
|
||||
"adopterConvention",
|
||||
ExecutionRequestObservationConvention.class,
|
||||
org.springframework.graphql.observation.DefaultExecutionRequestObservationConvention
|
||||
::new)
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context)
|
||||
.as("a platform default that cannot be replaced is not a default")
|
||||
.doesNotHaveBean(GraphQlRequestObservationConventionAdapter.class);
|
||||
});
|
||||
}
|
||||
}
|
||||
+63
@@ -7,6 +7,69 @@ import org.junit.jupiter.api.Test;
|
||||
/** Schema compatibility diff and breaking policy (Stable plan Task 10). */
|
||||
class GraphQlSchemaComparatorTest {
|
||||
|
||||
@Test
|
||||
void aFieldRemovedFromATypeExtensionIsBreaking() {
|
||||
GraphQlCompatibilityReport report =
|
||||
GraphQlSchemaComparator.compare(
|
||||
"type Query { a: String } extend type Query { b: String }", "type Query { a: String }");
|
||||
|
||||
assertThat(report.changesOf(GraphQlChangeKind.OUTPUT_FIELD_REMOVED))
|
||||
.as("a field an extension contributed is on the wire like any other")
|
||||
.hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFieldAddedByATypeExtensionIsReported() {
|
||||
GraphQlCompatibilityReport report =
|
||||
GraphQlSchemaComparator.compare(
|
||||
"type Query { a: String }", "type Query { a: String } extend type Query { b: String }");
|
||||
|
||||
assertThat(report.changesOf(GraphQlChangeKind.OUTPUT_FIELD_ADDED_NULLABLE)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExtensionThatStrengthensAnInputIsBreaking() {
|
||||
GraphQlCompatibilityReport report =
|
||||
GraphQlSchemaComparator.compare(
|
||||
"input OrderFilter { status: String } extend input OrderFilter { region: String }",
|
||||
"input OrderFilter { status: String } extend input OrderFilter { region: String! }");
|
||||
|
||||
assertThat(report.changesOf(GraphQlChangeKind.INPUT_FIELD_STRENGTHENED)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEnumValueRemovedFromAnExtensionIsReported() {
|
||||
GraphQlCompatibilityReport report =
|
||||
GraphQlSchemaComparator.compare(
|
||||
"enum Status { NEW } extend enum Status { ARCHIVED }", "enum Status { NEW }");
|
||||
|
||||
assertThat(report.changesOf(GraphQlChangeKind.ENUM_VALUE_REMOVED)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aUnionMemberRemovedFromAnExtensionIsReported() {
|
||||
GraphQlCompatibilityReport report =
|
||||
GraphQlSchemaComparator.compare(
|
||||
"type A { a: String } type B { b: String } union Result = A extend union Result = B",
|
||||
"type A { a: String } type B { b: String } union Result = A");
|
||||
|
||||
assertThat(report.changesOf(GraphQlChangeKind.UNION_MEMBER_REMOVED)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewordingAScalarsDescriptionIsNotACoercionChange() {
|
||||
GraphQlCompatibilityReport report =
|
||||
GraphQlSchemaComparator.compare(
|
||||
"\"An ISO-8601 instant.\" scalar Instant type Query { at: Instant }",
|
||||
"\"An instant, in ISO-8601.\" scalar Instant type Query { at: Instant }");
|
||||
|
||||
assertThat(report.changesOf(GraphQlChangeKind.SCALAR_DECLARATION_CHANGED))
|
||||
.as(
|
||||
"prose is not a coercion; a review triggered by an edited sentence trains people to "
|
||||
+ "approve the report without reading it")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredArgumentAdditionIsBreaking() {
|
||||
GraphQlCompatibilityReport report =
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.context;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The fingerprint that leaves this process has to survive someone guessing the identity behind it.
|
||||
*
|
||||
* <p>The interesting case is the low-entropy one, so the tests use the identifiers a real
|
||||
* deployment has: {@code user-42}, {@code tenant-a}. A plain digest of either is recovered in the
|
||||
* time it takes to hash a wordlist, which is what the dictionary test measures directly.
|
||||
*/
|
||||
class GraphQlIdentityFingerprinterTest {
|
||||
|
||||
private static final ActorRef ACTOR = ActorRef.authenticated("user-42");
|
||||
private static final TenantContext TENANT = TenantContext.fromTrustedSession("tenant-a");
|
||||
|
||||
@Test
|
||||
void aGuessableIdentityIsNotRecoverableFromItsFingerprint() {
|
||||
GraphQlIdentityFingerprinter fingerprinter =
|
||||
GraphQlIdentityFingerprinter.single("k1", randomKey());
|
||||
|
||||
String fingerprint = fingerprinter.actor(ACTOR);
|
||||
Map<String, String> dictionary = new LinkedHashMap<>();
|
||||
for (int candidate = 0; candidate < 100; candidate++) {
|
||||
dictionary.put(sha256Prefix("user-" + candidate), "user-" + candidate);
|
||||
}
|
||||
|
||||
assertThat(dictionary.keySet())
|
||||
.as("a digest of an enumerable identifier names its owner to anyone who enumerates it")
|
||||
.contains(sha256Prefix("user-42"));
|
||||
assertThat(dictionary)
|
||||
.as("the same enumeration must not resolve the keyed fingerprint")
|
||||
.doesNotContainKey(fingerprint.substring(fingerprint.indexOf(':') + 1));
|
||||
assertThat(fingerprint).doesNotContain("user-42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoKeysProduceTwoFingerprintsForOneIdentity() {
|
||||
assertThat(GraphQlIdentityFingerprinter.single("k1", randomKey()).actor(ACTOR))
|
||||
.as("a fingerprint a deployment cannot change is a fingerprint it cannot revoke")
|
||||
.isNotEqualTo(GraphQlIdentityFingerprinter.single("k2", randomKey()).actor(ACTOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameKeyProducesTheSameFingerprint() {
|
||||
byte[] secret = randomKey();
|
||||
|
||||
assertThat(GraphQlIdentityFingerprinter.single("k1", secret).actor(ACTOR))
|
||||
.as("a retry has to fingerprint to the stored value or idempotency never matches")
|
||||
.isEqualTo(GraphQlIdentityFingerprinter.single("k1", secret.clone()).actor(ACTOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFingerprintNamesTheKeyItWasComputedUnder() {
|
||||
byte[] retired = randomKey();
|
||||
byte[] active = randomKey();
|
||||
GraphQlIdentityFingerprinter rotated =
|
||||
GraphQlIdentityFingerprinter.of(Map.of("k1", retired, "k2", active), "k2");
|
||||
|
||||
assertThat(rotated.activeKeyId()).isEqualTo("k2");
|
||||
assertThat(rotated.actor(ACTOR)).startsWith("k2:");
|
||||
assertThat(rotated.keyIds())
|
||||
.as("records written under the retired key stay attributable while they are retained")
|
||||
.containsExactlyInAnyOrder("k1", "k2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anActorAndATenantOfTheSameNameFingerprintDifferently() {
|
||||
GraphQlIdentityFingerprinter fingerprinter =
|
||||
GraphQlIdentityFingerprinter.single("k1", randomKey());
|
||||
|
||||
assertThat(fingerprinter.actor(ActorRef.authenticated("acme")))
|
||||
.isNotEqualTo(fingerprinter.tenant(TenantContext.fromAuthenticatedCredential("acme")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTenantFingerprintNeverCarriesTheTenantName() {
|
||||
assertThat(GraphQlIdentityFingerprinter.single("k1", randomKey()).tenant(TENANT))
|
||||
.doesNotContain("tenant-a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRingWithoutAUsableKeyIsRefused() {
|
||||
byte[] usable = randomKey();
|
||||
|
||||
assertThatThrownBy(() -> GraphQlIdentityFingerprinter.of(Map.of(), "k1"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> GraphQlIdentityFingerprinter.of(Map.of("k1", usable), "k2"))
|
||||
.as("an active key that is not in the ring cannot fingerprint anything")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> GraphQlIdentityFingerprinter.single("k1", new byte[8]))
|
||||
.as("a short key is the part of a MAC an attacker attacks first")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> GraphQlIdentityFingerprinter.single("k:1", usable))
|
||||
.as("the key id is the fingerprint's prefix, so it cannot contain the separator")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* One generator for the whole class.
|
||||
*
|
||||
* <p>Seeding a fresh {@code SecureRandom} per call is the wasteful shape SpotBugs reports as
|
||||
* {@code DMI_RANDOM_USED_ONLY_ONCE}: each instance pays for seeding and then produces a single
|
||||
* value. The keys still never appear in this file, which is the property that matters.
|
||||
*/
|
||||
private static final SecureRandom KEYS = new SecureRandom();
|
||||
|
||||
private static byte[] randomKey() {
|
||||
byte[] secret = new byte[32];
|
||||
KEYS.nextBytes(secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** The unkeyed form, reproduced here only so the dictionary attack on it can be demonstrated. */
|
||||
private static String sha256Prefix(String source) {
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] prefix = new byte[16];
|
||||
System.arraycopy(digest, 0, prefix, 0, prefix.length);
|
||||
return HexFormat.of().formatHex(prefix);
|
||||
} catch (Exception unavailable) {
|
||||
throw new IllegalStateException(unavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -48,12 +48,12 @@ class GraphQlRequestContextTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void actorAndTenantExposeFingerprintsInsteadOfRawIdentifiers() {
|
||||
void actorAndTenantExposePartitionTokensInsteadOfRawIdentifiers() {
|
||||
ActorRef actor = ActorRef.authenticated("user-42");
|
||||
TenantContext tenant = TenantContext.fromTrustedSession("tenant-a");
|
||||
|
||||
assertThat(actor.fingerprint()).doesNotContain("user-42").hasSize(32);
|
||||
assertThat(tenant.fingerprint()).doesNotContain("tenant-a").hasSize(32);
|
||||
assertThat(actor.cachePartition()).doesNotContain("user-42").hasSize(32);
|
||||
assertThat(tenant.cachePartition()).doesNotContain("tenant-a").hasSize(32);
|
||||
assertThat(ActorRef.anonymous().authenticated()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
+81
-3
@@ -4,7 +4,10 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.ActorRef;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -15,11 +18,33 @@ import org.junit.jupiter.api.Test;
|
||||
/** Mutation idempotency scope and fingerprint (Stable plan Task 43). */
|
||||
class GraphQlMutationIdempotencyContextTest {
|
||||
|
||||
/**
|
||||
* One generator for the whole class, declared before the keys that draw from it.
|
||||
*
|
||||
* <p>Static initialisers run in textual order, so a generator declared below the two key
|
||||
* constants would still be null when they are built. Seeding a fresh {@code SecureRandom} per
|
||||
* call is also what SpotBugs reports as {@code DMI_RANDOM_USED_ONLY_ONCE}.
|
||||
*/
|
||||
private static final java.security.SecureRandom KEYS = new java.security.SecureRandom();
|
||||
|
||||
private static final String TENANT = "tenant-fingerprint";
|
||||
private static final String VERSION = "v1";
|
||||
private static final GraphQlMutationCoordinate CREATE =
|
||||
new GraphQlMutationCoordinate("Mutation.createOrder");
|
||||
|
||||
// Two rings standing for one deployment before and after a rotation. Both secrets are generated
|
||||
// here rather than written down, so nothing in this file is a key anyone could reuse.
|
||||
private static final GraphQlIdentityFingerprinter FIRST_KEY =
|
||||
GraphQlIdentityFingerprinter.single("k1", randomKey());
|
||||
private static final GraphQlIdentityFingerprinter ROTATED_KEY =
|
||||
GraphQlIdentityFingerprinter.single("k2", randomKey());
|
||||
|
||||
private static byte[] randomKey() {
|
||||
byte[] secret = new byte[32];
|
||||
KEYS.nextBytes(secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameKeyWithDifferentFingerprintIsConflict() {
|
||||
var first = context(CREATE, new GraphQlMutationFingerprint("sha256:a"));
|
||||
@@ -189,6 +214,7 @@ class GraphQlMutationIdempotencyContextTest {
|
||||
var derived =
|
||||
GraphQlMutationIdempotencyInterceptor.from(
|
||||
context,
|
||||
FIRST_KEY,
|
||||
CREATE,
|
||||
VERSION,
|
||||
Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"),
|
||||
@@ -198,16 +224,68 @@ class GraphQlMutationIdempotencyContextTest {
|
||||
.hasValueSatisfying(
|
||||
scope -> {
|
||||
assertThat(scope.key().value()).isEqualTo("request-1");
|
||||
assertThat(scope.actorFingerprint()).isEqualTo(context.actor().fingerprint());
|
||||
assertThat(scope.tenantFingerprint()).isEqualTo(context.tenant().fingerprint());
|
||||
assertThat(scope.actorFingerprint()).isEqualTo(FIRST_KEY.actor(context.actor()));
|
||||
assertThat(scope.tenantFingerprint()).isEqualTo(FIRST_KEY.tenant(context.tenant()));
|
||||
assertThat(scope.contractVersion()).isEqualTo(VERSION);
|
||||
});
|
||||
assertThat(
|
||||
GraphQlMutationIdempotencyInterceptor.from(
|
||||
context, CREATE, VERSION, Map.of(), Map.of()))
|
||||
context, FIRST_KEY, CREATE, VERSION, Map.of(), Map.of()))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotatingTheFingerprintKeyChangesTheStoredScope() {
|
||||
GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a");
|
||||
|
||||
String first = derivedScope(context, FIRST_KEY);
|
||||
String rotated = derivedScope(context, ROTATED_KEY);
|
||||
|
||||
assertThat(first)
|
||||
.as(
|
||||
"an unkeyed digest of a guessable actor is recovered by digesting the guesses, and a "
|
||||
+ "deployment that suspects its stored scopes have been read has no way to change "
|
||||
+ "them unless the fingerprint depends on a key it controls")
|
||||
.isNotEqualTo(rotated);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anActorAndATenantOfTheSameNameDoNotShareAFingerprint() {
|
||||
assertThat(FIRST_KEY.actor(ActorRef.authenticated("acme")))
|
||||
.as("a service account and a tenant that happen to share a name are two identities")
|
||||
.isNotEqualTo(FIRST_KEY.tenant(TenantContext.fromAuthenticatedCredential("acme")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivingAScopeWithoutAFingerprintKeyIsRefused() {
|
||||
GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GraphQlMutationIdempotencyInterceptor.from(
|
||||
context,
|
||||
null,
|
||||
CREATE,
|
||||
VERSION,
|
||||
Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"),
|
||||
Map.of("customerId", "c-1")))
|
||||
.as("a deployment with no key must not silently fall back to a recoverable digest")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static String derivedScope(
|
||||
GraphQlRequestContext context, GraphQlIdentityFingerprinter fingerprinter) {
|
||||
return GraphQlMutationIdempotencyInterceptor.from(
|
||||
context,
|
||||
fingerprinter,
|
||||
CREATE,
|
||||
VERSION,
|
||||
Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"),
|
||||
Map.of("customerId", "c-1"))
|
||||
.orElseThrow()
|
||||
.scope();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fingerprintsAndScopesNeverCarryRawInputOrActor() {
|
||||
var fingerprint = GraphQlMutationFingerprint.of(Map.of("card", "4111111111111111"));
|
||||
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchErrorPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator;
|
||||
import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts;
|
||||
import graphql.ExecutionInput;
|
||||
import graphql.ExecutionResult;
|
||||
import graphql.GraphQL;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.GraphQLSchema;
|
||||
import graphql.schema.idl.RuntimeWiring;
|
||||
import graphql.schema.idl.SchemaGenerator;
|
||||
import graphql.schema.idl.SchemaParser;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.dataloader.DataLoaderRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.graphql.execution.DefaultBatchLoaderRegistry;
|
||||
|
||||
/**
|
||||
* The batch policy has to be reached by an executing query, not only by a unit test.
|
||||
*
|
||||
* <p>Everything the platform says about N+1 lived in objects that a request never met: the registry
|
||||
* held {@code Object} and was connected to neither Spring's {@link
|
||||
* org.springframework.graphql.execution.BatchLoaderRegistry} nor java-dataloader, and the chunk
|
||||
* counts the contract suite asserted were numbers the caller had passed in. A suite that measures
|
||||
* its own argument passes whatever the runtime does.
|
||||
*
|
||||
* <p>So this drives the real path — the registrar registers with Spring's registry, Spring builds
|
||||
* the {@code DataLoaderRegistry} for one execution, graphql-java dispatches through it — and counts
|
||||
* calls on a fake downstream. Fifty parents that produce fifty calls, or a batch that outlives the
|
||||
* request budget, fail here and nowhere else.
|
||||
*/
|
||||
class GraphQlBatchLoaderRegistrationTest {
|
||||
|
||||
private static final GraphQlDataLoaderName LOADER = new GraphQlDataLoaderName("customer-by-id");
|
||||
|
||||
private static final String SCHEMA =
|
||||
"""
|
||||
type Query { orders: [Order!]! }
|
||||
type Order { id: ID!, customer: Customer }
|
||||
type Customer { id: ID!, name: String! }
|
||||
""";
|
||||
|
||||
@Test
|
||||
void fiftyParentsBecomeABoundedNumberOfDownstreamCalls() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
|
||||
|
||||
ExecutionResult result = fixture.execute(orders(50, 50), downstream);
|
||||
|
||||
assertThat(result.getErrors()).isEmpty();
|
||||
assertThat(resolvedCustomers(result)).hasSize(50);
|
||||
assertThat(downstream.calls)
|
||||
.as("fifty parents resolving one child each must not be fifty downstream calls")
|
||||
.hasValue(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneRequestLoadsARepeatedKeyOnce() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
|
||||
|
||||
ExecutionResult result = fixture.execute(orders(50, 5), downstream);
|
||||
|
||||
assertThat(result.getErrors()).isEmpty();
|
||||
assertThat(downstream.requestedKeys)
|
||||
.as("the loader dedupes within a request; fifty orders share five customers")
|
||||
.hasSize(5);
|
||||
assertThat(downstream.calls).hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneRequestsCacheIsNotHandedToTheNext() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
|
||||
|
||||
fixture.execute(orders(5, 5), downstream);
|
||||
fixture.execute(orders(5, 5), downstream);
|
||||
|
||||
assertThat(downstream.calls)
|
||||
.as(
|
||||
"a DataLoader cache that outlived its request would serve one caller's rows to the next")
|
||||
.hasValue(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyChunkRunsUnderTheRequestsOwnActorAndTenant() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
Fixture tenantA = fixture(chunkSize(20), Clock.systemUTC());
|
||||
Fixture tenantB = fixture(chunkSize(20), Clock.systemUTC(), "tenant-b");
|
||||
|
||||
tenantA.execute(orders(50, 50), downstream);
|
||||
Set<String> afterTenantA = Set.copyOf(downstream.observedScopes);
|
||||
tenantB.execute(orders(50, 50), downstream);
|
||||
|
||||
assertThat(afterTenantA)
|
||||
.as("one request's chunks all carry the same scope, or a chunk read for someone else")
|
||||
.hasSize(1);
|
||||
assertThat(downstream.observedScopes)
|
||||
.as("two tenants must not share one batch scope")
|
||||
.hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLoaderWithoutThePlatformContextFailsClosedRatherThanLoading() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
|
||||
|
||||
ExecutionResult result = fixture.executeWithoutRequestContext(orders(5, 5), downstream);
|
||||
|
||||
assertThat(result.getErrors())
|
||||
.as("a batch with no deadline, tenant or actor has nothing to bound or scope it")
|
||||
.isNotEmpty();
|
||||
assertThat(downstream.calls).hasValue(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBatchThatOverrunsTheRequestBudgetStopsAtTheChunkThatSpentIt() {
|
||||
MutableClock clock = new MutableClock(Instant.parse("2026-08-14T00:00:00Z"));
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
downstream.onCall = () -> clock.advance(Duration.ofMinutes(1));
|
||||
Fixture fixture = fixture(chunkSize(10), clock);
|
||||
|
||||
ExecutionResult result = fixture.execute(orders(50, 50), downstream);
|
||||
|
||||
assertThat(result.getErrors()).isNotEmpty();
|
||||
assertThat(downstream.calls)
|
||||
.as("the chunk after an exhausted budget must never reach the downstream")
|
||||
.hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLoaderThatAnswersAKeyNobodyAskedForIsRefused() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
downstream.extraKey = "c-from-another-question";
|
||||
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
|
||||
|
||||
ExecutionResult result = fixture.execute(orders(5, 5), downstream);
|
||||
|
||||
assertThat(result.getErrors())
|
||||
.as("matching cardinality is not matching keys, and the platform declares that a violation")
|
||||
.isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRowTheLoaderReportsAsNullRendersAsAMissingChild() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
downstream.nullValueKey = "c-2";
|
||||
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
|
||||
|
||||
ExecutionResult result = fixture.execute(orders(5, 5), downstream);
|
||||
|
||||
assertThat(result.getErrors())
|
||||
.as("a nullable relation with no row is data, not a failure of the whole batch")
|
||||
.isEmpty();
|
||||
assertThat(resolvedCustomers(result)).containsNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingRowUnderTheFieldErrorPolicyFailsTheField() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
downstream.omittedKey = "c-2";
|
||||
Fixture fixture =
|
||||
fixture(
|
||||
new GraphQlBatchPolicy(
|
||||
LOADER,
|
||||
20,
|
||||
Duration.ofSeconds(5),
|
||||
true,
|
||||
GraphQlMissingKeyPolicy.FIELD_ERROR,
|
||||
GraphQlBatchErrorPolicy.PER_KEY),
|
||||
Clock.systemUTC());
|
||||
|
||||
ExecutionResult result = fixture.execute(orders(5, 5), downstream);
|
||||
|
||||
assertThat(result.getErrors())
|
||||
.as("a loader declared as always resolving must not render its absence as legitimate null")
|
||||
.isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReactiveRuntimeWithoutABridgeRefusesTheLoader() {
|
||||
GraphQlBatchLoaderRegistrar registrar =
|
||||
new GraphQlBatchLoaderRegistrar(
|
||||
factory(chunkSize(20), Clock.systemUTC()), GraphQlExecutionProfile.REACTIVE_WEBFLUX);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
registrar.<String, Map<String, Object>>register(
|
||||
new DefaultBatchLoaderRegistry(), LOADER, (keys, batchContext) -> Map.of()))
|
||||
.as("an inline blocking chunk on an event loop stalls every request that loop is serving")
|
||||
.isInstanceOf(GraphQlExecutionProfileException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBridgedLoaderRunsOffTheCallingThreadAndStillSeesTheRequest() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
try (GraphQlBlockingBridge bridge = GraphQlBlockingBridge.bounded(1, 4)) {
|
||||
Fixture fixture =
|
||||
new Fixture(
|
||||
new GraphQlBatchLoaderRegistrar(
|
||||
factory(chunkSize(20), Clock.systemUTC()),
|
||||
bridge,
|
||||
GraphQlExecutionProfile.REACTIVE_WEBFLUX),
|
||||
"tenant-a",
|
||||
Clock.systemUTC());
|
||||
|
||||
ExecutionResult result = fixture.execute(orders(5, 5), downstream);
|
||||
|
||||
assertThat(result.getErrors()).isEmpty();
|
||||
assertThat(downstream.observedThreads)
|
||||
.as("the bridge exists to move the blocking call off the thread that subscribed")
|
||||
.doesNotContain(Thread.currentThread().getName());
|
||||
assertThat(downstream.observedBoundContexts)
|
||||
.as("a batch that arrives on the bridge thread with no context has no tenant")
|
||||
.containsExactly(Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aServletRuntimeLoadsOnTheThreadItWasGiven() {
|
||||
RecordingLoader downstream = new RecordingLoader();
|
||||
|
||||
fixture(chunkSize(20), Clock.systemUTC()).execute(orders(5, 5), downstream);
|
||||
|
||||
assertThat(downstream.observedThreads)
|
||||
.as("a second pool on a servlet stack is a queue and a wait that buys nothing")
|
||||
.containsExactly(Thread.currentThread().getName());
|
||||
}
|
||||
|
||||
private static GraphQlBatchPolicy chunkSize(int size) {
|
||||
return new GraphQlBatchPolicy(
|
||||
LOADER,
|
||||
size,
|
||||
Duration.ofSeconds(5),
|
||||
true,
|
||||
GraphQlMissingKeyPolicy.NULL_VALUE,
|
||||
GraphQlBatchErrorPolicy.PER_KEY);
|
||||
}
|
||||
|
||||
private static Fixture fixture(GraphQlBatchPolicy policy, Clock clock) {
|
||||
return fixture(policy, clock, "tenant-a");
|
||||
}
|
||||
|
||||
private static Fixture fixture(GraphQlBatchPolicy policy, Clock clock, String tenant) {
|
||||
return new Fixture(
|
||||
new GraphQlBatchLoaderRegistrar(
|
||||
factory(policy, clock), GraphQlExecutionProfile.BLOCKING_MVC),
|
||||
tenant,
|
||||
clock);
|
||||
}
|
||||
|
||||
private static GraphQlDataLoaderFactory factory(GraphQlBatchPolicy policy, Clock clock) {
|
||||
return new GraphQlDataLoaderFactory(
|
||||
new GraphQlBatchPolicyRegistry().register(policy), 100, clock);
|
||||
}
|
||||
|
||||
/** {@code count} orders spread over {@code customers} distinct customer ids. */
|
||||
private static List<Order> orders(int count, int customers) {
|
||||
List<Order> orders = new ArrayList<>();
|
||||
for (int index = 0; index < count; index++) {
|
||||
orders.add(new Order("o-" + index, "c-" + (index % customers)));
|
||||
}
|
||||
return orders;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> resolvedCustomers(ExecutionResult result) {
|
||||
Map<String, Object> data = result.getData();
|
||||
return ((List<Map<String, Object>>) data.get("orders"))
|
||||
.stream().map(order -> (Map<String, Object>) order.get("customer")).toList();
|
||||
}
|
||||
|
||||
private record Order(String id, String customerId) {}
|
||||
|
||||
/** The adopter's downstream call, counting what the platform actually asked it for. */
|
||||
private static final class RecordingLoader {
|
||||
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private final Set<String> requestedKeys = new LinkedHashSet<>();
|
||||
private final Set<String> observedScopes = new LinkedHashSet<>();
|
||||
private Runnable onCall = () -> {};
|
||||
private final Set<String> observedThreads = new LinkedHashSet<>();
|
||||
private final Set<Boolean> observedBoundContexts = new LinkedHashSet<>();
|
||||
private String extraKey;
|
||||
private String nullValueKey;
|
||||
private String omittedKey;
|
||||
|
||||
Map<String, Map<String, Object>> load(List<String> keys, GraphQlBatchContext context) {
|
||||
calls.incrementAndGet();
|
||||
requestedKeys.addAll(keys);
|
||||
observedScopes.add(context.cacheScope());
|
||||
observedThreads.add(Thread.currentThread().getName());
|
||||
observedBoundContexts.add(GraphQlContextPropagator.current().isPresent());
|
||||
onCall.run();
|
||||
Map<String, Map<String, Object>> rows = new LinkedHashMap<>();
|
||||
for (String key : keys) {
|
||||
if (key.equals(omittedKey)) {
|
||||
continue;
|
||||
}
|
||||
rows.put(
|
||||
key, key.equals(nullValueKey) ? null : Map.of("id", key, "name", "customer " + key));
|
||||
}
|
||||
if (extraKey != null) {
|
||||
rows.put(extraKey, Map.of("id", extraKey, "name", "customer " + extraKey));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/** Registrar, schema and execution wired the way Spring wires them at runtime. */
|
||||
private static final class Fixture {
|
||||
|
||||
private final GraphQlBatchLoaderRegistrar registrar;
|
||||
private final String tenant;
|
||||
private final Clock clock;
|
||||
|
||||
Fixture(GraphQlBatchLoaderRegistrar registrar, String tenant, Clock clock) {
|
||||
this.registrar = registrar;
|
||||
this.tenant = tenant;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
ExecutionResult execute(List<Order> orders, RecordingLoader downstream) {
|
||||
GraphQlRequestContext context =
|
||||
GraphQlRequestContexts.testContext(tenant)
|
||||
.withDeadline(GraphQlDeadline.after(Duration.ofSeconds(5), clock));
|
||||
return execute(orders, downstream, context);
|
||||
}
|
||||
|
||||
ExecutionResult executeWithoutRequestContext(List<Order> orders, RecordingLoader downstream) {
|
||||
return execute(orders, downstream, null);
|
||||
}
|
||||
|
||||
private ExecutionResult execute(
|
||||
List<Order> orders, RecordingLoader downstream, GraphQlRequestContext context) {
|
||||
|
||||
DefaultBatchLoaderRegistry batchLoaders = new DefaultBatchLoaderRegistry();
|
||||
registrar.<String, Map<String, Object>>register(
|
||||
batchLoaders, LOADER, (keys, batchContext) -> downstream.load(keys, batchContext));
|
||||
|
||||
ExecutionInput input =
|
||||
ExecutionInput.newExecutionInput("{ orders { id customer { id name } } }")
|
||||
.graphQLContext(
|
||||
builder -> {
|
||||
if (context != null) {
|
||||
builder.of(GraphQlRequestContext.CONTEXT_KEY, context);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
// A registry per execution, exactly as Spring builds one per request: this is what makes the
|
||||
// loader's cache request-scoped rather than a process-wide store of other people's rows.
|
||||
DataLoaderRegistry dataLoaders = new DataLoaderRegistry();
|
||||
batchLoaders.registerDataLoaders(dataLoaders, input.getGraphQLContext());
|
||||
|
||||
return GraphQL.newGraphQL(schema(orders))
|
||||
.build()
|
||||
.execute(input.transform(builder -> builder.dataLoaderRegistry(dataLoaders)));
|
||||
}
|
||||
|
||||
private static GraphQLSchema schema(List<Order> orders) {
|
||||
DataFetcher<?> customer =
|
||||
environment -> {
|
||||
Order order = environment.getSource();
|
||||
return environment
|
||||
.<String, Map<String, Object>>getDataLoader(LOADER.value())
|
||||
.load(order.customerId());
|
||||
};
|
||||
return new SchemaGenerator()
|
||||
.makeExecutableSchema(
|
||||
new SchemaParser().parse(SCHEMA),
|
||||
RuntimeWiring.newRuntimeWiring()
|
||||
.type("Query", builder -> builder.dataFetcher("orders", environment -> orders))
|
||||
.type("Order", builder -> builder.dataFetcher("customer", customer))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
/** A clock the test advances itself, so elapsed time is caused by the work under test. */
|
||||
private static final class MutableClock extends Clock {
|
||||
|
||||
private Instant current;
|
||||
|
||||
MutableClock(Instant start) {
|
||||
this.current = start;
|
||||
}
|
||||
|
||||
void advance(Duration step) {
|
||||
current = current.plus(step);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.ActorRef;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationNames;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter;
|
||||
import graphql.ExecutionInput;
|
||||
import graphql.ExecutionResult;
|
||||
import io.micrometer.core.instrument.Meter;
|
||||
import io.micrometer.core.instrument.observation.DefaultMeterObservationHandler;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.graphql.observation.ExecutionRequestObservationContext;
|
||||
|
||||
/**
|
||||
* The bound, measured where a metrics backend would see it.
|
||||
*
|
||||
* <p>The platform's tag policy was a value object with no consumer: Spring for GraphQL emits the
|
||||
* {@code graphql.request} observation and asks its own convention what to tag it with, so the
|
||||
* cardinality policy applied to nothing that was ever exported. These cases drive the observation
|
||||
* through a real {@code ObservationRegistry} and a registry that actually stores series, and count
|
||||
* the meters afterwards.
|
||||
*/
|
||||
class GraphQlRequestObservationConventionAdapterTest {
|
||||
|
||||
private static final int ARBITRARY_NAMES = 10_000;
|
||||
|
||||
private final SimpleMeterRegistry meters = new SimpleMeterRegistry();
|
||||
private final ObservationRegistry observations = ObservationRegistry.create();
|
||||
|
||||
GraphQlRequestObservationConventionAdapterTest() {
|
||||
observations.observationConfig().observationHandler(new DefaultMeterObservationHandler(meters));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenThousandArbitraryNamesProduceOneExportedSeries() {
|
||||
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
|
||||
|
||||
for (int index = 0; index < ARBITRARY_NAMES; index++) {
|
||||
observe(adapter, "Query" + index, null);
|
||||
}
|
||||
|
||||
assertThat(requestSeries())
|
||||
.as("a client that renames its operation per request must not rename the time series")
|
||||
.hasSize(1);
|
||||
assertThat(operationNameLabels()).containsExactly(GraphQlOperationNameCardinality.UNREGISTERED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNameTheValueTypeWouldRejectIsCollapsedRatherThanThrown() {
|
||||
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
|
||||
|
||||
// Valid GraphQL names, invalid platform operation names: too short, and leading underscore.
|
||||
observe(adapter, "ab", null);
|
||||
observe(adapter, "_internal", null);
|
||||
|
||||
assertThat(requestSeries())
|
||||
.as("an observation convention that throws takes the request down with it")
|
||||
.hasSize(1);
|
||||
assertThat(operationNameLabels()).containsExactly(GraphQlOperationNameCardinality.UNREGISTERED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRegisteredOperationKeepsItsOwnSeriesAndTheRestCollapse() {
|
||||
var convention =
|
||||
new GraphQlRequestObservationConvention(
|
||||
GraphQlSensitiveAttributeFilter.standard(),
|
||||
new GraphQlOperationNameCardinality(Set.of("OrderById")));
|
||||
var adapter = new GraphQlRequestObservationConventionAdapter(convention);
|
||||
|
||||
observe(adapter, "OrderById", null);
|
||||
for (int index = 0; index < 100; index++) {
|
||||
observe(adapter, "Query" + index, null);
|
||||
}
|
||||
|
||||
assertThat(operationNameLabels())
|
||||
.containsExactlyInAnyOrder("OrderById", GraphQlOperationNameCardinality.UNREGISTERED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theExportedTagsCarryTheRequestProfileAndOutcome() {
|
||||
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
|
||||
|
||||
observe(adapter, "Anything", requestContext("partner"));
|
||||
|
||||
Meter.Id id = requestSeries().get(0);
|
||||
assertThat(id.getTag("graphql.client.profile")).isEqualTo("partner");
|
||||
assertThat(id.getTag("graphql.outcome"))
|
||||
.isEqualTo(GraphQlRequestObservationConventionAdapter.OUTCOME_SUCCESS);
|
||||
assertThat(id.getTag("graphql.operation.type")).isEqualTo("QUERY");
|
||||
assertThat(id.getTag("graphql.document"))
|
||||
.as("the document is the unbounded value the allowlist exists to drop")
|
||||
.isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnauthenticatedRequestIsCountedUnderTheAnonymousProfile() {
|
||||
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
|
||||
|
||||
observe(adapter, "Anything", null);
|
||||
|
||||
assertThat(requestSeries().get(0).getTag("graphql.client.profile")).isEqualTo("anonymous");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theObservationNameMatchesTheOneSpringAlreadyEmits() {
|
||||
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
|
||||
|
||||
assertThat(adapter.getName()).isEqualTo(GraphQlObservationNames.REQUEST);
|
||||
}
|
||||
|
||||
private static GraphQlRequestObservationConvention collapsingConvention() {
|
||||
return GraphQlRequestObservationConvention.standard();
|
||||
}
|
||||
|
||||
private void observe(
|
||||
GraphQlRequestObservationConventionAdapter adapter,
|
||||
String operationName,
|
||||
GraphQlRequestContext requestContext) {
|
||||
|
||||
ExecutionInput input =
|
||||
ExecutionInput.newExecutionInput("{ __typename }").operationName(operationName).build();
|
||||
if (requestContext != null) {
|
||||
input.getGraphQLContext().put(GraphQlRequestContext.CONTEXT_KEY, requestContext);
|
||||
}
|
||||
var context = new ExecutionRequestObservationContext(input);
|
||||
context.setExecutionResult(ExecutionResult.newExecutionResult().data(Map.of()).build());
|
||||
Observation.createNotStarted(adapter, () -> context, observations).observe(() -> {});
|
||||
}
|
||||
|
||||
private static GraphQlRequestContext requestContext(String profile) {
|
||||
return new GraphQlRequestContext(
|
||||
ActorRef.anonymous(),
|
||||
TenantContext.system("public"),
|
||||
new GraphQlClientProfile(profile),
|
||||
Locale.ENGLISH,
|
||||
new GraphQlOperationId("order.by-id"),
|
||||
"trace-1",
|
||||
new GraphQlDeadline(Instant.now().plus(Duration.ofSeconds(5))));
|
||||
}
|
||||
|
||||
private List<Meter.Id> requestSeries() {
|
||||
return meters.getMeters().stream()
|
||||
.map(Meter::getId)
|
||||
.filter(id -> GraphQlObservationNames.REQUEST.equals(id.getName()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<String> operationNameLabels() {
|
||||
return requestSeries().stream()
|
||||
.map(id -> id.getTag("graphql.operation.name"))
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.ActorRef;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
|
||||
import dev.caskeleton.application.security.ObjectAccessDecision;
|
||||
import dev.caskeleton.application.security.ObjectAccessPolicy;
|
||||
import dev.caskeleton.application.security.ObjectAccessRequest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The GraphQL side of object access is a mapping, and nothing more.
|
||||
*
|
||||
* <p>What the application receives has to be four plain values, because a contract that names this
|
||||
* leaf's request context cannot be implemented from application-core at all — the dependency gate
|
||||
* refuses the edge, and the transport would arrive in every persistence adapter behind the use
|
||||
* case. These cases pin the mapping: the actor and tenant come from the request context, the
|
||||
* decision comes back unaltered in meaning, and an answer the application failed to give is a
|
||||
* denial rather than an omission.
|
||||
*/
|
||||
class ApplicationObjectAuthorizationTest {
|
||||
|
||||
@Test
|
||||
void theApplicationSeesTheActorAndTenantFromTheRequestContext() {
|
||||
List<ObjectAccessRequest> asked = new ArrayList<>();
|
||||
var bridge =
|
||||
new ApplicationObjectAuthorization(
|
||||
request -> {
|
||||
asked.add(request);
|
||||
return ObjectAccessDecision.allow();
|
||||
});
|
||||
|
||||
bridge.authorize(context(ActorRef.authenticated("actor-1"), "tenant-a"), "Order", "order-1");
|
||||
|
||||
assertThat(asked).hasSize(1);
|
||||
assertThat(asked.get(0).actor()).contains("actor-1");
|
||||
assertThat(asked.get(0).tenantId()).isEqualTo("tenant-a");
|
||||
assertThat(asked.get(0).objectType()).isEqualTo("Order");
|
||||
assertThat(asked.get(0).objectId()).isEqualTo("order-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnauthenticatedCallerReachesTheApplicationWithNoActor() {
|
||||
List<ObjectAccessRequest> asked = new ArrayList<>();
|
||||
var bridge =
|
||||
new ApplicationObjectAuthorization(
|
||||
request -> {
|
||||
asked.add(request);
|
||||
return ObjectAccessDecision.allow();
|
||||
});
|
||||
|
||||
bridge.authorize(context(ActorRef.anonymous(), "public"), "Order", "order-1");
|
||||
|
||||
assertThat(asked.get(0).actor())
|
||||
.as("an anonymous reference is not an identity the rule may match on")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHiddenDenialStaysHiddenAcrossTheBoundary() {
|
||||
var bridge =
|
||||
new ApplicationObjectAuthorization(
|
||||
request -> ObjectAccessDecision.denyHidingExistence("OBJECT_NOT_FOUND"));
|
||||
|
||||
GraphQlAuthorizationDecision decision =
|
||||
bridge.authorize(context(ActorRef.authenticated("actor-1"), "tenant-a"), "Order", "o-1");
|
||||
|
||||
assertThat(decision.allowed()).isFalse();
|
||||
assertThat(decision.code()).isEqualTo("OBJECT_NOT_FOUND");
|
||||
assertThat(decision.hideExistence())
|
||||
.as("dropping this flag turns a not-found into a forbidden, which discloses the object")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBatchFormAsksOnceAndAnswersEveryId() {
|
||||
List<String> batches = new ArrayList<>();
|
||||
ObjectAccessPolicy policy =
|
||||
new ObjectAccessPolicy() {
|
||||
@Override
|
||||
public ObjectAccessDecision decide(ObjectAccessRequest request) {
|
||||
throw new AssertionError("the batch form must not fall back to one call per object");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, ObjectAccessDecision> decideAll(
|
||||
String actorId, String tenantId, String objectType, List<String> objectIds) {
|
||||
batches.add(String.join(",", objectIds));
|
||||
return Map.of(
|
||||
"order-1", ObjectAccessDecision.allow(),
|
||||
"order-2", ObjectAccessDecision.deny("OBJECT_NOT_AUTHORIZED"));
|
||||
}
|
||||
};
|
||||
|
||||
Map<String, GraphQlAuthorizationDecision> decisions =
|
||||
new ApplicationObjectAuthorization(policy)
|
||||
.authorizeAll(
|
||||
context(ActorRef.authenticated("actor-1"), "tenant-a"),
|
||||
"Order",
|
||||
List.of("order-1", "order-2"));
|
||||
|
||||
assertThat(batches).containsExactly("order-1,order-2");
|
||||
assertThat(decisions.get("order-1").allowed()).isTrue();
|
||||
assertThat(decisions.get("order-2").allowed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anObjectTheApplicationDidNotAnswerForIsDenied() {
|
||||
ObjectAccessPolicy policy =
|
||||
new ObjectAccessPolicy() {
|
||||
@Override
|
||||
public ObjectAccessDecision decide(ObjectAccessRequest request) {
|
||||
return ObjectAccessDecision.allow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, ObjectAccessDecision> decideAll(
|
||||
String actorId, String tenantId, String objectType, List<String> objectIds) {
|
||||
return Map.of("order-1", ObjectAccessDecision.allow());
|
||||
}
|
||||
};
|
||||
|
||||
Map<String, GraphQlAuthorizationDecision> decisions =
|
||||
new ApplicationObjectAuthorization(policy)
|
||||
.authorizeAll(
|
||||
context(ActorRef.authenticated("actor-1"), "tenant-a"),
|
||||
"Order",
|
||||
List.of("order-1", "order-2"));
|
||||
|
||||
assertThat(decisions.get("order-2").allowed())
|
||||
.as("no decision is the one state that must never read as permission")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
private static GraphQlRequestContext context(ActorRef actor, String tenant) {
|
||||
return new GraphQlRequestContext(
|
||||
actor,
|
||||
TenantContext.system(tenant),
|
||||
new GraphQlClientProfile("first-party"),
|
||||
Locale.ENGLISH,
|
||||
new GraphQlOperationId("order.by-id"),
|
||||
"trace-1",
|
||||
new GraphQlDeadline(Instant.now().plus(Duration.ofSeconds(5))));
|
||||
}
|
||||
}
|
||||
+56
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -63,6 +64,61 @@ class GraphQlCancellationAggregationTest {
|
||||
assertThat(suppressed).isInstanceOf(IllegalStateException.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailingRequestListenerNeverStopsTheOtherWorkFromStopping() {
|
||||
List<String> stopped = new ArrayList<>();
|
||||
var cancellation = GraphQlCancellation.create();
|
||||
cancellation.onCancel(() -> stopped.add("statement"));
|
||||
cancellation.onCancel(
|
||||
() -> {
|
||||
stopped.add("downstream-call");
|
||||
throw new IllegalStateException("the downstream client refused to close");
|
||||
});
|
||||
cancellation.onCancel(() -> stopped.add("publisher"));
|
||||
|
||||
assertThatThrownBy(cancellation::cancel).isInstanceOf(IllegalStateException.class);
|
||||
|
||||
assertThat(stopped)
|
||||
.as("a deadline that reaches only the first listener is not a cancellation")
|
||||
.containsExactlyInAnyOrder("statement", "downstream-call", "publisher");
|
||||
assertThat(cancellation.cancelled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRequestListenerFailingLateIsAttachedToTheFirstFailure() {
|
||||
var cancellation = GraphQlCancellation.create();
|
||||
cancellation.onCancel(
|
||||
() -> {
|
||||
throw new IllegalStateException("registered-first");
|
||||
});
|
||||
cancellation.onCancel(
|
||||
() -> {
|
||||
throw new IllegalArgumentException("registered-second");
|
||||
});
|
||||
|
||||
assertThatThrownBy(cancellation::cancel)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.satisfies(
|
||||
thrown ->
|
||||
assertThat(thrown.getSuppressed())
|
||||
.hasSize(1)
|
||||
.allSatisfy(
|
||||
suppressed ->
|
||||
assertThat(suppressed).isInstanceOf(IllegalArgumentException.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRequestListenerRegisteredAfterCancellationStillRunsExactlyOnce() {
|
||||
List<String> stopped = new ArrayList<>();
|
||||
var cancellation = GraphQlCancellation.create();
|
||||
cancellation.cancel();
|
||||
|
||||
cancellation.onCancel(() -> stopped.add("late"));
|
||||
cancellation.cancel();
|
||||
|
||||
assertThat(stopped).containsExactly("late");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailingUpstreamHookNeverStopsTheOthersFromStopping() {
|
||||
List<String> stopped = new ArrayList<>();
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
// 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 {
|
||||
|
||||
@@ -27,44 +27,35 @@ dependencies {
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
}
|
||||
|
||||
tasks.register('jpaPersistenceRedactionContractTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
filter {
|
||||
includeTestsMatching(
|
||||
'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails')
|
||||
includeTestsMatching(
|
||||
'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode')
|
||||
}
|
||||
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'
|
||||
strictTestLanes {
|
||||
lane('jpaPersistenceRedactionContractTest') {
|
||||
description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.'
|
||||
// Named rather than tagged. This lane is JPA evidence's proof that a database failure never
|
||||
// reaches a log line or a span, and it must stay exactly these two contracts — a tag would
|
||||
// let a later test opt itself in and change what the evidence covers.
|
||||
requires(
|
||||
'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails',
|
||||
'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode')
|
||||
}
|
||||
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}")
|
||||
|
||||
lane('webSecurityBoundaryTest') {
|
||||
tag = 'security-boundary'
|
||||
description = 'Runs hermetic JWT/JWKS and CORS filter-boundary contracts with no skips.'
|
||||
customize = { test ->
|
||||
test.shouldRunAfter test.project.tasks.named('test')
|
||||
test.jvmArgs '-Duser.timezone=UTC'
|
||||
test.afterSuite { descriptor, result ->
|
||||
if (descriptor.parent == null && result.skippedTestCount > 0) {
|
||||
throw new GradleException(
|
||||
"webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* The two beans the callback endpoints need, and neither of which existed.
|
||||
*
|
||||
* <p>{@code NotificationCallbackMvcController}, {@code NotificationCallbackWebFluxHandler} and
|
||||
* {@code CallbackWebFluxConfiguration} all take {@code CallbackRequestFactory} as a constructor
|
||||
* argument, and it was produced nowhere in production code — the only instantiation in the
|
||||
* repository was inside a test. So the documented, env-registered switch {@code
|
||||
* APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=true} did not enable callbacks; it made the
|
||||
* application fail to start on an unsatisfied dependency. An operator following the configuration
|
||||
* reference got a deployment that would not boot.
|
||||
*
|
||||
* <p>Conditioned on the same switch as the controller, so a deployment that leaves callbacks off
|
||||
* carries no URL resolver and no clock binding for them.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.notification.platform.callbacks",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
public class CallbackRequestConfiguration {
|
||||
|
||||
/**
|
||||
* Resolves the URL a provider actually called.
|
||||
*
|
||||
* <p>The trusted-proxy set is empty by default, and that default is the safe one rather than the
|
||||
* convenient one: with no entry, forwarded headers are never honoured and the resolver uses what
|
||||
* the container observed. Honouring them unconditionally would let any caller choose the URL that
|
||||
* gets signature-verified, which defeats the signature. A deployment behind a load balancer names
|
||||
* its proxies explicitly.
|
||||
*
|
||||
* @param trustedProxies peers whose forwarded headers may be believed
|
||||
* @return the resolver
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(ExternalRequestUrlResolver.class)
|
||||
public ExternalRequestUrlResolver externalRequestUrlResolver(
|
||||
@Value("${ca-skeleton.notification.platform.callbacks.trusted-proxies:}")
|
||||
Set<String> trustedProxies) {
|
||||
return new ExternalRequestUrlResolver(trustedProxies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the canonical callback request both transports share.
|
||||
*
|
||||
* @param urlResolver the external URL resolver
|
||||
* @param clock the clock timestamps are read from
|
||||
* @return the factory
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(CallbackRequestFactory.class)
|
||||
public CallbackRequestFactory callbackRequestFactory(
|
||||
ExternalRequestUrlResolver urlResolver, Clock clock) {
|
||||
return new CallbackRequestFactory(urlResolver, clock);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Clock;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Turning callbacks on produces the beans the endpoints need.
|
||||
*
|
||||
* <p>The endpoints already existed and were already conditioned on this switch; what did not exist
|
||||
* was any producer of {@code CallbackRequestFactory}. Every reference to it in production code was
|
||||
* a constructor parameter, so the switch documented in the configuration reference did not enable a
|
||||
* feature — it failed the startup. Nothing caught that because the controller's own test builds the
|
||||
* factory by hand, which is precisely the dependency a running application has to supply.
|
||||
*/
|
||||
class CallbackRequestConfigurationTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(CallbackRequestConfiguration.class))
|
||||
.withUserConfiguration(ClockConfiguration.class);
|
||||
|
||||
@Test
|
||||
@DisplayName("callbacks on supplies both beans the endpoints take")
|
||||
void callbacksOnSuppliesTheBeans() {
|
||||
runner
|
||||
.withPropertyValues("ca-skeleton.notification.platform.callbacks.enabled=true")
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasSingleBean(ExternalRequestUrlResolver.class)
|
||||
.hasSingleBean(CallbackRequestFactory.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callbacks off supplies neither, so an off deployment carries nothing")
|
||||
void callbacksOffSuppliesNothing() {
|
||||
runner.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.doesNotHaveBean(ExternalRequestUrlResolver.class)
|
||||
.doesNotHaveBean(CallbackRequestFactory.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no trusted proxy is configured by default")
|
||||
void noTrustedProxyByDefault() {
|
||||
// The safe default rather than the convenient one: with no entry, a forwarded header cannot
|
||||
// choose the URL that gets signature-verified.
|
||||
runner
|
||||
.withPropertyValues("ca-skeleton.notification.platform.callbacks.enabled=true")
|
||||
.run(context -> assertThat(context).hasSingleBean(ExternalRequestUrlResolver.class));
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ClockConfiguration {
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -278,6 +278,16 @@ class NotificationCallbackMvcControllerTest {
|
||||
byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<
|
||||
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
|
||||
byProviderRequestIdHash(
|
||||
ProviderProfileId profileId,
|
||||
dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash
|
||||
providerRequestIdHash) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Never reached: projection runs only after a signature has been accepted. */
|
||||
@@ -397,5 +407,10 @@ class NotificationCallbackMvcControllerTest {
|
||||
dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean bindAttempt(ProviderEventRecordId eventId, DeliveryAttemptId attemptId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
// io.grpc coordinates the BOM does not manage).
|
||||
description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)'
|
||||
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md.
|
||||
//
|
||||
// The design models the SDK as separate Gradle modules. This repository's fail-closed 19-leaf
|
||||
// The design models the SDK as separate Gradle modules. This repository's fail-closed module
|
||||
// registry outranks that layout, so the module boundaries are packages under
|
||||
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
|
||||
dependencies {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here).
|
||||
//
|
||||
// The design models the platform as 19 separate Gradle modules. This repository's fail-closed
|
||||
// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module
|
||||
// module registry (src/config/architecture/modules.json) outranks that layout, so the module
|
||||
// boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and
|
||||
// HttpClientModuleBoundaryTest enforces the design's module dependency table.
|
||||
description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)'
|
||||
@@ -73,33 +73,12 @@ dependencies {
|
||||
// Performance certification and JMH benchmarks are separate source sets for their own reason: they
|
||||
// are slow, they assert on resource bounds rather than behaviour, and they must never be part of
|
||||
// the default unit lane.
|
||||
sourceSets {
|
||||
testkit {
|
||||
java.srcDir 'src/testkit/java'
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
httpClientPerformanceTest {
|
||||
java.srcDir 'src/httpClientPerformanceTest/java'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
jmh {
|
||||
java.srcDir 'src/jmh/java'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
// The testkit compiles against exactly what a test does: testImplementation already extends
|
||||
// implementation, so this is the module's own dependencies plus the test libraries.
|
||||
testkitImplementation.extendsFrom testImplementation
|
||||
testkitRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
httpClientPerformanceTestImplementation.extendsFrom testImplementation
|
||||
httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
jmhImplementation.extendsFrom testImplementation
|
||||
jmhRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
strictTestLanes {
|
||||
// The testkit compiles against exactly what a test does: `implementation` inheritance runs
|
||||
// through testImplementation, so this is the module's own dependencies plus the test libraries.
|
||||
sourceSet('testkit') { compilesAgainst 'main' }
|
||||
sourceSet('httpClientPerformanceTest') { compilesAgainst 'main', 'testkit' }
|
||||
sourceSet('jmh') { compilesAgainst 'main', 'testkit' }
|
||||
}
|
||||
|
||||
// Every test lane compiles and runs against the testkit.
|
||||
@@ -166,69 +145,58 @@ tasks.named('test', Test) {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('httpClientBlockHoundTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-blockhound' }
|
||||
applyContractSelection(it)
|
||||
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
|
||||
jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
|
||||
// The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure.
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
// Four tag-selected lanes, declared rather than assembled.
|
||||
//
|
||||
// Two of them — the stable contract suite and the security suite — did not carry
|
||||
// failOnNoDiscoveredTests at all. Five lanes were written by copying the block above, and the
|
||||
// property that makes a lane mean anything was lost on two of the copies, so the cross-transport
|
||||
// contract suite and the SSRF/credential-leak suite would each have reported success on discovering
|
||||
// nothing. Declaring the lanes removes the opportunity: the convention has no opt-out.
|
||||
strictTestLanes {
|
||||
lane('httpClientBlockHoundTest') {
|
||||
tag = 'httpclient-blockhound'
|
||||
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
|
||||
customize = { test ->
|
||||
applyContractSelection(test)
|
||||
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
|
||||
test.jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
|
||||
}
|
||||
}
|
||||
lane('httpClientStableContractTest') {
|
||||
tag = 'httpclient-contract'
|
||||
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
|
||||
customize = { test -> applyContractSelection(test) }
|
||||
}
|
||||
lane('httpClientSecurityTest') {
|
||||
tag = 'httpclient-security'
|
||||
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
|
||||
customize = { test -> applyContractSelection(test) }
|
||||
}
|
||||
// Its own source set rather than a tag, so the source set is the selection.
|
||||
lane('httpClientPerformanceTest') {
|
||||
sourceSet = 'httpClientPerformanceTest'
|
||||
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
|
||||
customize = { test ->
|
||||
applyContractSelection(test)
|
||||
test.systemProperty 'performance.assertions.enabled',
|
||||
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
|
||||
}
|
||||
}
|
||||
lane('httpClientFailureInjectionTest') {
|
||||
tag = 'httpclient-fault'
|
||||
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker ' +
|
||||
'(design §28.3).'
|
||||
customize = { test ->
|
||||
applyContractSelection(test)
|
||||
// The upstream image is mutable by default. Passing a digest here is what makes a red
|
||||
// fault run attributable to this repository rather than to someone else's image push.
|
||||
test.systemProperty 'httpclient.fault.httpbin.image',
|
||||
(project.findProperty('httpclient.fault.httpbin.image')
|
||||
?: 'kennethreitz/httpbin:latest').toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('httpClientStableContractTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-contract' }
|
||||
applyContractSelection(it)
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('httpClientSecurityTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-security' }
|
||||
applyContractSelection(it)
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('httpClientFailureInjectionTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-fault' }
|
||||
applyContractSelection(it)
|
||||
// The upstream image is mutable by default. Passing a digest here is what makes a red fault
|
||||
// run attributable to this repository rather than to someone else's image push.
|
||||
systemProperty 'httpclient.fault.httpbin.image',
|
||||
(project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString()
|
||||
// A fault suite that never injected a fault must not report success, so a selected lane with no
|
||||
// discovered test is an error rather than an empty pass.
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('httpClientPerformanceTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
|
||||
testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs
|
||||
classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
applyContractSelection(it)
|
||||
systemProperty 'performance.assertions.enabled',
|
||||
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('jmh', JavaExec) {
|
||||
group = 'verification'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
|
||||
@@ -20,8 +20,13 @@ dependencies {
|
||||
implementation 'io.projectreactor:reactor-core'
|
||||
// JSON Schema 2020-12 validation of template variables, using the same validator and version the
|
||||
// messaging adapter already depends on rather than a second implementation of the same spec.
|
||||
// The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML
|
||||
// parser onto the runtime classpath would add attack surface for a format nothing reads.
|
||||
//
|
||||
// Jackson's YAML dataformat is excluded because schemas arrive as JSON strings and a second
|
||||
// parser for a format this leaf never reads is surface for nothing. It does not remove YAML from
|
||||
// the runtime — org.yaml:snakeyaml is on this classpath via spring-boot-starter, which is how
|
||||
// Spring Boot reads application.yml. The comment here used to claim the stronger outcome, and
|
||||
// the resolved graph had said otherwise for as long as it stood; dependencyPolicy below now
|
||||
// states the claim the build can check.
|
||||
// Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring
|
||||
// starter, which would drag a view resolver and a servlet integration onto an outbound
|
||||
// adapter that renders strings and never serves a request.
|
||||
@@ -37,3 +42,13 @@ dependencies {
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
}
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
// The exclusion above, stated as something the build verifies rather than something a comment
|
||||
// asserts. verifyDependencyPolicy resolves runtimeClasspath and fails if the coordinate is present.
|
||||
dependencyPolicy {
|
||||
absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml',
|
||||
because: 'schemas arrive as JSON strings; a second YAML parser is surface for a format ' +
|
||||
'this leaf never reads'
|
||||
absent 'tools.jackson.dataformat:jackson-dataformat-yaml',
|
||||
because: 'the Jackson 3 coordinate of the same parser, excluded for the same reason'
|
||||
}
|
||||
|
||||
+20
-9
@@ -86,13 +86,19 @@ public record NotificationPlatformSettings(
|
||||
/**
|
||||
* The largest body the platform can retain, derived rather than chosen.
|
||||
*
|
||||
* <p>It was one mebibyte, while the ciphertext column holds 65,536 bytes and encryption adds a
|
||||
* 12-byte nonce and a 16-byte tag. Three layers each enforced a different number: configuration
|
||||
* allowed a mebibyte, the MVC controller hard-coded 65,536, and the database rejected anything
|
||||
* over 65,536 *after* encryption — so a body of exactly the configured maximum passed every
|
||||
* check above the database and failed the CHECK constraint, having already been acknowledged.
|
||||
* <p>It was one mebibyte, while the ciphertext column holds 65,536 bytes and the envelope adds
|
||||
* a version byte, a key id, a nonce and a tag. Three layers each enforced a different number:
|
||||
* configuration allowed a mebibyte, the MVC controller hard-coded 65,536, and the database
|
||||
* rejected anything over 65,536 *after* encryption — so a body of exactly the configured
|
||||
* maximum passed every check above the database and failed the CHECK constraint, having already
|
||||
* been acknowledged.
|
||||
*
|
||||
* <p>Read from the protector rather than restated, because a number written twice is a number
|
||||
* that drifts the first time the envelope gains a field.
|
||||
*/
|
||||
private static final long MAX_BODY_CEILING = 65_536L - 28L;
|
||||
private static final long MAX_BODY_CEILING =
|
||||
dev.caskeleton.adapter.outbound.notification.platform.security
|
||||
.AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES;
|
||||
|
||||
public Callbacks {
|
||||
Objects.requireNonNull(replaySkew, "replaySkew");
|
||||
@@ -100,7 +106,12 @@ public record NotificationPlatformSettings(
|
||||
throw new IllegalArgumentException(
|
||||
"max-body-bytes must be 1.."
|
||||
+ MAX_BODY_CEILING
|
||||
+ "; the ciphertext column holds 65536 bytes and encryption adds 28");
|
||||
+ "; the ciphertext column holds "
|
||||
+ dev.caskeleton.adapter.outbound.notification.platform.security
|
||||
.AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES
|
||||
+ " bytes and the envelope adds "
|
||||
+ dev.caskeleton.adapter.outbound.notification.platform.security
|
||||
.AesGcmCallbackPayloadProtection.ENVELOPE_OVERHEAD_BYTES);
|
||||
}
|
||||
if (replaySkew.isNegative()) {
|
||||
throw new IllegalArgumentException("replay-skew must not be negative");
|
||||
@@ -109,8 +120,8 @@ public record NotificationPlatformSettings(
|
||||
|
||||
/** Conservative defaults. */
|
||||
public static Callbacks defaults() {
|
||||
// The storable maximum, not the column size: encryption adds 28 bytes, so a default of
|
||||
// 65,536 was a default that could not be stored.
|
||||
// The storable maximum, not the column size: the envelope adds a version byte, a key id, a
|
||||
// nonce and a tag, so a default of 65,536 was a default that could not be stored.
|
||||
return new Callbacks(false, MAX_BODY_CEILING, Duration.ofMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which key purposes a given configuration actually needs.
|
||||
*
|
||||
* <p>Startup demanded all eight, always. That is fail-closed in the wrong direction: it made every
|
||||
* deployment provision and rotate keys for capabilities it had switched off — a Web Push signing
|
||||
* key for a platform with no Web Push profile, a callback signing key for a platform with no
|
||||
* callback endpoint — and a key that exists but is never used is a key nobody notices leaking. It
|
||||
* also made the eight look equally load-bearing, so nothing distinguished the four that every mode
|
||||
* needs from the four that follow a capability.
|
||||
*
|
||||
* <p>The direction that must not weaken is the other one: a capability that is switched <em>on</em>
|
||||
* and whose key is missing still refuses the boot, because the alternative is discovering it on a
|
||||
* user's notification. This class decides only what is required; validation of whatever is supplied
|
||||
* happens regardless, so an unused key that is configured is still checked rather than trusted.
|
||||
*/
|
||||
public final class NotificationSecretRequirements {
|
||||
|
||||
private NotificationSecretRequirements() {}
|
||||
|
||||
/**
|
||||
* The purposes this configuration must supply.
|
||||
*
|
||||
* @param settings the bound platform configuration
|
||||
* @return the required purposes, never empty
|
||||
*/
|
||||
public static Set<SecretPurpose> requiredBy(NotificationPlatformSettings settings) {
|
||||
Objects.requireNonNull(settings, "settings");
|
||||
Set<SecretPurpose> required = EnumSet.copyOf(ALWAYS);
|
||||
|
||||
if (settings.callbacks().enabled()) {
|
||||
// The callback endpoint verifies a provider signature and fingerprints the payload for
|
||||
// deduplication. Both happen on the first callback that arrives, so neither key can be
|
||||
// deferred to "when it is needed".
|
||||
required.add(SecretPurpose.CALLBACK_SIGNING);
|
||||
required.add(SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, NotificationPlatformSettings.Provider> entry :
|
||||
settings.providers().entrySet()) {
|
||||
NotificationPlatformSettings.Provider profile = entry.getValue();
|
||||
if (!profile.enabled()) {
|
||||
continue;
|
||||
}
|
||||
ProviderType type = ProviderType.parse(entry.getKey(), profile.type());
|
||||
if (authenticatesWithAPlatformCredential(type)) {
|
||||
required.add(SecretPurpose.PROVIDER_CREDENTIAL);
|
||||
}
|
||||
if (type == ProviderType.WEB_PUSH) {
|
||||
required.add(SecretPurpose.VAPID_SIGNING);
|
||||
}
|
||||
if (profile.callbackSigningSecretRef() != null
|
||||
&& !profile.callbackSigningSecretRef().isBlank()) {
|
||||
// A profile that names a signing key ref intends to verify or produce signatures whatever
|
||||
// the platform-wide callback switch says.
|
||||
required.add(SecretPurpose.CALLBACK_SIGNING);
|
||||
}
|
||||
}
|
||||
return Set.copyOf(required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a family authenticates to its provider with a key this platform holds.
|
||||
*
|
||||
* <p>SMTP does not: its relay address, user and password come from Spring's own {@code
|
||||
* spring.mail.*} through the injected mail sender, which is why {@code
|
||||
* SmtpProviderRuntimeAssembler} never touches the secret store. Demanding a provider credential
|
||||
* for an SMTP-only deployment asked an operator to invent a secret with nothing to authenticate
|
||||
* to.
|
||||
*/
|
||||
private static boolean authenticatesWithAPlatformCredential(ProviderType type) {
|
||||
return type != ProviderType.SMTP;
|
||||
}
|
||||
|
||||
/**
|
||||
* The purposes every mode needs, including {@code INGEST_ONLY}.
|
||||
*
|
||||
* <p>Each of these is on the accept path rather than the dispatch path, so switching every
|
||||
* provider off does not switch any of them off. Contact points are encrypted and their lookup
|
||||
* hashes computed when a recipient is resolved; notification variables are encrypted at rest by
|
||||
* the record mapper, which takes the protection as a constructor argument with no fallback; and
|
||||
* provider request ids are hashed by the attempt store and the event ledger on every row they
|
||||
* write.
|
||||
*/
|
||||
private static final Set<SecretPurpose> ALWAYS =
|
||||
Set.of(
|
||||
SecretPurpose.CONTACT_ENCRYPTION,
|
||||
SecretPurpose.CONTACT_LOOKUP_HMAC,
|
||||
SecretPurpose.PAYLOAD_ENCRYPTION,
|
||||
SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC);
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Opens the attachments an email submission declares.
|
||||
*
|
||||
* <p>Every email provider needs the same three things in the same order — resolve, verify, close —
|
||||
* and each of them is a silent failure when a second copy gets one wrong: an unresolved attachment
|
||||
* becomes a mail missing the document it is about, an unverified one becomes bytes nobody approved,
|
||||
* and an unclosed one becomes a leaked stream that only shows up under load.
|
||||
*
|
||||
* <p>The integrity check runs before the provider call, not after. A digest or size that does not
|
||||
* match what the caller pinned at submit time means these are not the approved bytes, and finding
|
||||
* that out once the mail has left is finding it out too late.
|
||||
*/
|
||||
public final class EmailAttachments {
|
||||
|
||||
private EmailAttachments() {}
|
||||
|
||||
/**
|
||||
* Resolves and verifies everything the content declares.
|
||||
*
|
||||
* <p>The caller closes the result on every path, including the failing ones. Content that is not
|
||||
* email, or email that declares nothing, resolves to an empty list rather than to a failure —
|
||||
* having no attachment is the normal case.
|
||||
*/
|
||||
public static List<ResolvedAttachment> open(
|
||||
AttachmentIntegrityGuard guard, ProviderSubmission submission) {
|
||||
Objects.requireNonNull(guard, "guard");
|
||||
Objects.requireNonNull(submission, "submission");
|
||||
if (!(submission.content().content() instanceof EmailContent email)
|
||||
|| email.attachments().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
AttachmentAccessContext context =
|
||||
new AttachmentAccessContext(
|
||||
new TenantId(submission.profile().environment()), submission.attemptId());
|
||||
List<ResolvedAttachment> resolved = new ArrayList<>(email.attachments().size());
|
||||
try {
|
||||
for (var reference : email.attachments()) {
|
||||
resolved.add(guard.resolve(reference, context));
|
||||
}
|
||||
return List.copyOf(resolved);
|
||||
} catch (RuntimeException failure) {
|
||||
// Everything already opened is closed before the failure propagates. Half a resolution is
|
||||
// still half a set of open streams.
|
||||
closeAll(resolved);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes everything that was opened, whatever the send did. */
|
||||
public static void closeAll(List<ResolvedAttachment> attachments) {
|
||||
Objects.requireNonNull(attachments, "attachments");
|
||||
for (ResolvedAttachment attachment : attachments) {
|
||||
try {
|
||||
attachment.close();
|
||||
} catch (Exception ignored) {
|
||||
// A stream that will not close is not a reason to change the send's outcome, and a failed
|
||||
// send is exactly when a leaked one would otherwise go unnoticed.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-13
@@ -1,24 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import dev.caskeleton.application.notification.platform.security.AccessContext;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -42,25 +46,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
|
||||
|
||||
private final NotificationHttpGateway gateway;
|
||||
private final SesRequestMapper mapper;
|
||||
private final AttachmentIntegrityGuard attachmentGuard;
|
||||
private final SesFailureClassifier classifier;
|
||||
private final ContactPointProtector protector;
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final ProviderCredentialManager credentials;
|
||||
private final String accessKeyId;
|
||||
private final Clock clock;
|
||||
|
||||
public SesNotificationProviderAdapter(
|
||||
NotificationHttpGateway gateway,
|
||||
SesRequestMapper mapper,
|
||||
AttachmentIntegrityGuard attachmentGuard,
|
||||
SesFailureClassifier classifier,
|
||||
ContactPointProtector protector,
|
||||
SecretMaterialProvider secrets,
|
||||
ProviderCredentialManager credentials,
|
||||
String accessKeyId,
|
||||
Clock clock) {
|
||||
this.gateway = Objects.requireNonNull(gateway, "gateway");
|
||||
this.mapper = Objects.requireNonNull(mapper, "mapper");
|
||||
this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard");
|
||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||
this.protector = Objects.requireNonNull(protector, "protector");
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.credentials = Objects.requireNonNull(credentials, "credentials");
|
||||
this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
@@ -77,8 +84,21 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
|
||||
|
||||
@Override
|
||||
public ProviderCapabilities capabilities() {
|
||||
// The payload ceiling is the mapper's own constant rather than a second copy of the number:
|
||||
// the runtime plans against what is declared here and the mapper refuses against what it holds,
|
||||
// and two spellings of the same limit is one of them being wrong.
|
||||
return new ProviderCapabilities(
|
||||
false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1));
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
SesRequestMapper.MAX_MESSAGE_BYTES,
|
||||
Duration.ofDays(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -97,13 +117,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
|
||||
throw new IllegalArgumentException("SES requires an email contact point");
|
||||
}
|
||||
|
||||
var request =
|
||||
mapper.map(
|
||||
submission,
|
||||
address.normalized(),
|
||||
accessKeyId,
|
||||
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
|
||||
clock.instant());
|
||||
// Resolved and verified before the request is shaped, and closed as soon as it is. An
|
||||
// attachment only reaches the wire through the raw MIME message the mapper builds from these
|
||||
// streams, so they have to be open for exactly that long and no longer.
|
||||
List<ResolvedAttachment> opened = EmailAttachments.open(attachmentGuard, submission);
|
||||
NotificationHttpRequest request;
|
||||
try {
|
||||
request =
|
||||
mapper.map(
|
||||
submission,
|
||||
address.normalized(),
|
||||
opened,
|
||||
accessKeyId,
|
||||
// This profile's credential at the generation the submission was planned against, not
|
||||
// the platform's one current provider credential. Sharing a single key across every
|
||||
// profile made one leaked SES account's key a leak of every provider account, and
|
||||
// made a per-profile rotation inexpressible.
|
||||
credentials.materialFor(
|
||||
submission.profile().profileId(), submission.profile().credentialGeneration()),
|
||||
clock.instant());
|
||||
} finally {
|
||||
EmailAttachments.closeAll(opened);
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationHttpResponse response = gateway.exchange(request);
|
||||
|
||||
+5
-1
@@ -20,7 +20,11 @@ public record SesProviderProperties(
|
||||
Objects.requireNonNull(senderIdentity, "senderIdentity");
|
||||
Objects.requireNonNull(configurationSet, "configurationSet");
|
||||
Objects.requireNonNull(timeout, "timeout");
|
||||
NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint");
|
||||
// See WebhookSubscription for why this is the stronger guard now. An SES endpoint is operator
|
||||
// configured rather than user supplied, so loopback stays available for local and contract
|
||||
// profiles; what is refused is a configured endpoint that resolves into the deployment's own
|
||||
// network.
|
||||
NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);
|
||||
if (region.isBlank() || senderIdentity.isBlank()) {
|
||||
throw new IllegalArgumentException("region and senderIdentity must not be blank");
|
||||
}
|
||||
|
||||
+137
-14
@@ -2,54 +2,98 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import jakarta.mail.MessagingException;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Builds the signed SES v2 send request. */
|
||||
/**
|
||||
* Builds the signed SES v2 send request.
|
||||
*
|
||||
* <p>Two content shapes, chosen by what the notification actually carries. {@code Simple} is a
|
||||
* subject and two bodies and nothing else, so an email that declares an attachment is assembled as
|
||||
* a MIME message and sent as {@code Raw} content instead. Content that declared an attachment used
|
||||
* to be sent as {@code Simple} regardless: the document was silently absent from the mail SES sent
|
||||
* and the attempt was still recorded as delivered, which is a recipient told to read something that
|
||||
* is not there.
|
||||
*
|
||||
* <p>The MIME message is built by the factory the SMTP provider already uses, rather than by a
|
||||
* second assembly of the same rendered email. Multipart layout, UTF-8 and the header-separator
|
||||
* rejection that keeps a subject from turning a notification into someone else's mail are decided
|
||||
* once; two builders would be two places for those answers to drift apart.
|
||||
*
|
||||
* <p>What the raw path still cannot express is refused before the request is signed, so nothing has
|
||||
* been sent when it happens: content that is not email, an attachment the resolver did not hand
|
||||
* back, and a message larger than SES will accept.
|
||||
*/
|
||||
public final class SesRequestMapper {
|
||||
|
||||
/**
|
||||
* The largest message SES accepts, measured on the bytes that go on the wire.
|
||||
*
|
||||
* <p>Measured after assembly rather than against the declared attachment sizes, because base64
|
||||
* transfer encoding adds a third: a set of parts that clears the limit before encoding and
|
||||
* exceeds it after would be rejected by SES with the attempt already made, and an attempt made is
|
||||
* an attempt the evidence model has to reason about.
|
||||
*/
|
||||
public static final long MAX_MESSAGE_BYTES = 10_000_000L;
|
||||
|
||||
private static final String PATH = "/v2/email/outbound-emails";
|
||||
|
||||
private final SesProviderProperties properties;
|
||||
private final AwsSignatureV4Signer signer;
|
||||
private final SmtpMimeMessageFactory mimeFactory;
|
||||
|
||||
public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) {
|
||||
public SesRequestMapper(
|
||||
SesProviderProperties properties,
|
||||
AwsSignatureV4Signer signer,
|
||||
SmtpMimeMessageFactory mimeFactory) {
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.signer = Objects.requireNonNull(signer, "signer");
|
||||
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
|
||||
}
|
||||
|
||||
/** Map one submission into a signed request. */
|
||||
/**
|
||||
* Map one submission into a signed request.
|
||||
*
|
||||
* @param attachments the already resolved and verified attachments, open for the length of this
|
||||
* call; the caller closes them
|
||||
*/
|
||||
public NotificationHttpRequest map(
|
||||
ProviderSubmission submission,
|
||||
String recipientAddress,
|
||||
List<ResolvedAttachment> attachments,
|
||||
String accessKeyId,
|
||||
byte[] secretAccessKey,
|
||||
Instant signedAt) {
|
||||
Objects.requireNonNull(submission, "submission");
|
||||
Objects.requireNonNull(recipientAddress, "recipientAddress");
|
||||
Objects.requireNonNull(attachments, "attachments");
|
||||
if (!(submission.content().content() instanceof EmailContent email)) {
|
||||
throw new IllegalArgumentException("SES requires email content");
|
||||
}
|
||||
|
||||
Map<String, Object> simple = new LinkedHashMap<>();
|
||||
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
|
||||
Map<String, Object> bodyParts = new LinkedHashMap<>();
|
||||
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
|
||||
email
|
||||
.htmlBody()
|
||||
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
|
||||
simple.put("Body", bodyParts);
|
||||
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("FromEmailAddress", properties.senderIdentity());
|
||||
payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress)));
|
||||
payload.put("Content", Map.of("Simple", simple));
|
||||
payload.put("Destination", Map.of("ToAddresses", List.of(recipientAddress)));
|
||||
payload.put("Content", content(submission, email, recipientAddress, attachments));
|
||||
properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name));
|
||||
|
||||
byte[] body =
|
||||
@@ -83,4 +127,83 @@ public final class SesRequestMapper {
|
||||
body,
|
||||
properties.timeout());
|
||||
}
|
||||
|
||||
/**
|
||||
* The content shape this email needs.
|
||||
*
|
||||
* <p>The decision is made from what the content <em>declares</em>, not from what was handed in:
|
||||
* an email that declares an attachment and arrives with fewer than it declared must not fall back
|
||||
* to {@code Simple}, because that is precisely the send that leaves the document behind and
|
||||
* reports success.
|
||||
*/
|
||||
private Map<String, Object> content(
|
||||
ProviderSubmission submission,
|
||||
EmailContent email,
|
||||
String recipientAddress,
|
||||
List<ResolvedAttachment> attachments) {
|
||||
if (email.attachments().isEmpty()) {
|
||||
return Map.of("Simple", simple(email));
|
||||
}
|
||||
if (attachments.size() != email.attachments().size()) {
|
||||
throw rejection(
|
||||
new IllegalStateException(
|
||||
"the submission declares "
|
||||
+ email.attachments().size()
|
||||
+ " attachments and "
|
||||
+ attachments.size()
|
||||
+ " were opened"));
|
||||
}
|
||||
return Map.of("Raw", Map.of("Data", rawMessage(submission, recipientAddress, attachments)));
|
||||
}
|
||||
|
||||
/** The MIME message, base64 encoded as the SES v2 JSON binding requires for a blob. */
|
||||
private String rawMessage(
|
||||
ProviderSubmission submission,
|
||||
String recipientAddress,
|
||||
List<ResolvedAttachment> attachments) {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try {
|
||||
mimeFactory
|
||||
.create(submission, recipientAddress, properties.senderIdentity(), attachments)
|
||||
.writeTo(buffer);
|
||||
} catch (IOException | MessagingException failure) {
|
||||
// The cause, never the content: which step of assembly failed is what an operator needs, and
|
||||
// the bytes it failed on are the recipient's document.
|
||||
throw rejection(failure);
|
||||
}
|
||||
|
||||
byte[] message = buffer.toByteArray();
|
||||
if (message.length > MAX_MESSAGE_BYTES) {
|
||||
throw new ProviderPayloadLimitException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(message);
|
||||
}
|
||||
|
||||
private static Map<String, Object> simple(EmailContent email) {
|
||||
Map<String, Object> simple = new LinkedHashMap<>();
|
||||
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
|
||||
Map<String, Object> bodyParts = new LinkedHashMap<>();
|
||||
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
|
||||
email
|
||||
.htmlBody()
|
||||
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
|
||||
simple.put("Body", bodyParts);
|
||||
return simple;
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal, carrying what caused it.
|
||||
*
|
||||
* <p>One descriptor for every shaping failure — it is what ends up on the delivery row, and a
|
||||
* per-check code there is a metric-cardinality problem. The cause is what tells an operator which
|
||||
* check fired.
|
||||
*/
|
||||
private static NotificationValidationException rejection(Throwable cause) {
|
||||
return new NotificationValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD),
|
||||
cause);
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -10,6 +10,8 @@ import dev.caskeleton.application.notification.platform.provider.ResolvedAttachm
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -72,8 +74,16 @@ public final class SmtpMimeMessageFactory {
|
||||
helper.setText(email.textBody(), false);
|
||||
}
|
||||
for (ResolvedAttachment attachment : attachments) {
|
||||
byte[] bytes = read(attachment);
|
||||
// A source that can be read again, not the resolver's one-shot stream. JavaMail reads an
|
||||
// attachment twice — once to choose the part's transfer encoding, once to write the part —
|
||||
// and the second read of an already drained stream returned nothing. The part that went out
|
||||
// announced a filename and carried no bytes, so the mail arrived with an empty attachment
|
||||
// and the attempt was still recorded as accepted.
|
||||
helper.addAttachment(
|
||||
attachment.displayName(), () -> attachment.content(), attachment.contentType());
|
||||
attachment.displayName(),
|
||||
() -> new ByteArrayInputStream(bytes),
|
||||
attachment.contentType());
|
||||
}
|
||||
for (var header : email.options().approvedHeaders().entrySet()) {
|
||||
requireHeaderSafe(header.getKey(), "approved header name");
|
||||
@@ -86,6 +96,30 @@ public final class SmtpMimeMessageFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the attachment into memory once.
|
||||
*
|
||||
* <p>Bounded by the size the integrity guard already pinned against the reference, and the read
|
||||
* is checked against it: a stream that turns out to be longer or shorter than the size that was
|
||||
* verified is not the content that was approved, whatever its reported digest said.
|
||||
*/
|
||||
private static byte[] read(ResolvedAttachment attachment) {
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = attachment.content().readAllBytes();
|
||||
} catch (IOException unreadable) {
|
||||
throw rejection(unreadable);
|
||||
}
|
||||
if (bytes.length != attachment.size()) {
|
||||
// The count, never the bytes: how much was read is diagnostic, what was read is the
|
||||
// recipient's document.
|
||||
throw rejection(
|
||||
new IllegalStateException(
|
||||
"attachment declared " + attachment.size() + " bytes and read " + bytes.length));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void requireHeaderSafe(String value, String field) {
|
||||
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
|
||||
// The field name, never the value: a header-injection attempt is exactly the payload that
|
||||
|
||||
+6
-51
@@ -1,10 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
@@ -38,8 +39,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
|
||||
private final ContactPointProtector protector;
|
||||
private final SmtpProviderProperties properties;
|
||||
private final Executor executor;
|
||||
private final dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
|
||||
attachmentGuard;
|
||||
private final AttachmentIntegrityGuard attachmentGuard;
|
||||
|
||||
public SmtpNotificationProviderAdapter(
|
||||
SmtpDispatch dispatch,
|
||||
@@ -48,8 +48,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
|
||||
ContactPointProtector protector,
|
||||
SmtpProviderProperties properties,
|
||||
Executor executor,
|
||||
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
|
||||
attachmentGuard) {
|
||||
AttachmentIntegrityGuard attachmentGuard) {
|
||||
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
|
||||
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
|
||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||
@@ -96,7 +95,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
|
||||
// Resolved, verified and closed around the send. The factory was handed List.of() whatever the
|
||||
// content asked for, so an email with attachments went out without them — the caller was told
|
||||
// it was accepted, and the recipient received a message missing the thing it was about.
|
||||
List<ResolvedAttachment> opened = resolve(submission);
|
||||
List<ResolvedAttachment> opened = EmailAttachments.open(attachmentGuard, submission);
|
||||
try {
|
||||
dispatch.send(
|
||||
mimeFactory.create(
|
||||
@@ -105,51 +104,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
|
||||
} catch (SmtpDispatchException failure) {
|
||||
return classifier.classify(failure, elapsedSince(startedNanos));
|
||||
} finally {
|
||||
// Closed on every path. A resolver hands back an open stream, and a failed send is exactly
|
||||
// when a leaked one goes unnoticed.
|
||||
opened.forEach(SmtpNotificationProviderAdapter::closeQuietly);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and verifies every attachment the content declares.
|
||||
*
|
||||
* <p>The integrity guard runs before the provider call, not after: a digest or size that does not
|
||||
* match what the caller declared means the bytes are not the bytes that were approved, and
|
||||
* discovering that after the mail has left is discovering it too late.
|
||||
*/
|
||||
private List<ResolvedAttachment> resolve(ProviderSubmission submission) {
|
||||
if (!(submission.content().content() instanceof EmailContent email)
|
||||
|| email.attachments().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ResolvedAttachment> resolved = new java.util.ArrayList<>(email.attachments().size());
|
||||
try {
|
||||
for (var reference : email.attachments()) {
|
||||
// The guard resolves and verifies size and digest in one step, so an attachment whose
|
||||
// bytes are not the approved bytes never reaches the MIME factory.
|
||||
resolved.add(
|
||||
attachmentGuard.resolve(
|
||||
reference,
|
||||
new dev.caskeleton.application.notification.platform.provider
|
||||
.AttachmentAccessContext(
|
||||
new dev.caskeleton.application.notification.platform.api.TenantId(
|
||||
submission.profile().environment()),
|
||||
submission.attemptId())));
|
||||
}
|
||||
return List.copyOf(resolved);
|
||||
} catch (RuntimeException failure) {
|
||||
// Everything already opened is closed before the failure propagates.
|
||||
resolved.forEach(SmtpNotificationProviderAdapter::closeQuietly);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeQuietly(ResolvedAttachment attachment) {
|
||||
try {
|
||||
attachment.close();
|
||||
} catch (Exception ignored) {
|
||||
// A stream that will not close is not a reason to change the send's outcome.
|
||||
EmailAttachments.closeAll(opened);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -52,7 +52,7 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
|
||||
properties.canonicalCallbackUrl(),
|
||||
parameters,
|
||||
request.header("x-twilio-signature").orElse(null),
|
||||
secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material());
|
||||
signingKey());
|
||||
return valid
|
||||
? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters))
|
||||
: CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH");
|
||||
@@ -65,6 +65,26 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
|
||||
return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* The key this profile's callbacks are signed with.
|
||||
*
|
||||
* <p>Verification used the platform's one current callback signing key, so every Twilio profile
|
||||
* in a deployment shared it: a subaccount whose token leaked could forge status callbacks for any
|
||||
* other, and a profile could not be rotated on its own. {@code callbackSigningKeyRef} is the
|
||||
* profile's own reference, and a reference naming a key issued for another purpose is a
|
||||
* configuration fault rather than a signature that quietly never matches.
|
||||
*/
|
||||
private byte[] signingKey() {
|
||||
var key = secrets.keyById(properties.callbackSigningKeyRef());
|
||||
if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) {
|
||||
throw new IllegalStateException(
|
||||
"twilio profile for account "
|
||||
+ properties.accountSid()
|
||||
+ " names a key that is not a callback signing key");
|
||||
}
|
||||
return key.material();
|
||||
}
|
||||
|
||||
private static Map<String, String> parseForm(byte[] body) {
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
String raw = new String(body, StandardCharsets.UTF_8);
|
||||
|
||||
+11
@@ -12,6 +12,10 @@ import java.util.Optional;
|
||||
* request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a
|
||||
* server-side reconstruction disagree with the signature — the most common cause of "valid webhook,
|
||||
* failed verification".
|
||||
*
|
||||
* <p>{@code callbackSigningKeyRef} is this profile's own signing key, mirroring the profile's
|
||||
* {@code callback-signing-secret-ref} setting. Verification used the platform's single current
|
||||
* callback signing key, so every profile shared one secret.
|
||||
*/
|
||||
public record TwilioProviderProperties(
|
||||
URI endpoint,
|
||||
@@ -19,6 +23,7 @@ public record TwilioProviderProperties(
|
||||
Optional<String> messagingServiceSid,
|
||||
Optional<String> fromNumber,
|
||||
String canonicalCallbackUrl,
|
||||
String callbackSigningKeyRef,
|
||||
Duration timeout,
|
||||
Duration maxReconciliationAge) {
|
||||
|
||||
@@ -28,11 +33,17 @@ public record TwilioProviderProperties(
|
||||
Objects.requireNonNull(messagingServiceSid, "messagingServiceSid");
|
||||
Objects.requireNonNull(fromNumber, "fromNumber");
|
||||
Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl");
|
||||
Objects.requireNonNull(callbackSigningKeyRef, "callbackSigningKeyRef");
|
||||
Objects.requireNonNull(timeout, "timeout");
|
||||
Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge");
|
||||
if (accountSid.isBlank()) {
|
||||
throw new IllegalArgumentException("accountSid");
|
||||
}
|
||||
if (callbackSigningKeyRef.isBlank()) {
|
||||
// Blank would fall back to whatever key is current, which is the platform-wide sharing this
|
||||
// reference exists to end.
|
||||
throw new IllegalArgumentException("callbackSigningKeyRef");
|
||||
}
|
||||
if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"exactly one of messagingServiceSid or fromNumber must be configured");
|
||||
|
||||
+15
-10
@@ -5,13 +5,12 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.http.Notif
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
|
||||
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
@@ -37,19 +36,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
|
||||
private final NotificationHttpGateway gateway;
|
||||
private final TwilioProviderProperties properties;
|
||||
private final TwilioStatusNormalizer normalizer;
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final ProviderCredentialManager credentials;
|
||||
private final Clock clock;
|
||||
|
||||
public TwilioReconciliationCapability(
|
||||
NotificationHttpGateway gateway,
|
||||
TwilioProviderProperties properties,
|
||||
TwilioStatusNormalizer normalizer,
|
||||
SecretMaterialProvider secrets,
|
||||
ProviderCredentialManager credentials,
|
||||
Clock clock) {
|
||||
this.gateway = Objects.requireNonNull(gateway, "gateway");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.credentials = Objects.requireNonNull(credentials, "credentials");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@@ -75,7 +74,8 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get()));
|
||||
NotificationHttpResponse response =
|
||||
gateway.exchange(statusRequest(attempt, messageSid.get()));
|
||||
if (!response.isSuccessful()) {
|
||||
return CompletableFuture.completedFuture(
|
||||
new ReconciliationResult.Failed(
|
||||
@@ -111,14 +111,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
|
||||
};
|
||||
}
|
||||
|
||||
private NotificationHttpRequest statusRequest(String messageSid) {
|
||||
String credentials =
|
||||
private NotificationHttpRequest statusRequest(
|
||||
DeliveryAttemptSnapshot attempt, String messageSid) {
|
||||
// The credential the attempt was made with, not whichever one is current. A status query is a
|
||||
// question about work that already happened, and asking it with a newer generation's token
|
||||
// fails once a rotation has landed — precisely when reconciliation matters most.
|
||||
String authorization =
|
||||
Base64.getEncoder()
|
||||
.encodeToString(
|
||||
(properties.accountSid()
|
||||
+ ":"
|
||||
+ new String(
|
||||
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
|
||||
credentials.materialFor(
|
||||
attempt.providerProfileId(), attempt.credentialGeneration()),
|
||||
StandardCharsets.UTF_8))
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
@@ -131,7 +136,7 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
|
||||
+ "/Messages/"
|
||||
+ messageSid
|
||||
+ ".json"),
|
||||
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)),
|
||||
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + authorization)),
|
||||
new byte[0],
|
||||
properties.timeout());
|
||||
}
|
||||
|
||||
+9
-6
@@ -4,6 +4,7 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderRe
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
@@ -15,8 +16,6 @@ import dev.caskeleton.application.notification.platform.provider.ProviderSubmiss
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
|
||||
import dev.caskeleton.application.notification.platform.security.AccessContext;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -40,19 +39,19 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt
|
||||
private final TwilioRequestMapper mapper;
|
||||
private final TwilioFailureClassifier classifier;
|
||||
private final ContactPointProtector protector;
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final ProviderCredentialManager credentials;
|
||||
|
||||
public TwilioSmsProviderAdapter(
|
||||
NotificationHttpGateway gateway,
|
||||
TwilioRequestMapper mapper,
|
||||
TwilioFailureClassifier classifier,
|
||||
ContactPointProtector protector,
|
||||
SecretMaterialProvider secrets) {
|
||||
ProviderCredentialManager credentials) {
|
||||
this.gateway = Objects.requireNonNull(gateway, "gateway");
|
||||
this.mapper = Objects.requireNonNull(mapper, "mapper");
|
||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||
this.protector = Objects.requireNonNull(protector, "protector");
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.credentials = Objects.requireNonNull(credentials, "credentials");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -93,7 +92,11 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt
|
||||
mapper.map(
|
||||
submission,
|
||||
phone.e164(),
|
||||
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material());
|
||||
// This profile's auth token at the generation the submission was planned against. One
|
||||
// platform-wide provider credential meant a leak of one Twilio account's token was a
|
||||
// leak of every profile's, whichever provider they belonged to.
|
||||
credentials.materialFor(
|
||||
submission.profile().profileId(), submission.profile().credentialGeneration()));
|
||||
|
||||
try {
|
||||
NotificationHttpResponse response = gateway.exchange(request);
|
||||
|
||||
+48
-3
@@ -10,6 +10,8 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
|
||||
@@ -82,10 +84,23 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
|
||||
return Set.of(Channel.WEBHOOK);
|
||||
}
|
||||
|
||||
/** The largest body this adapter will put on the wire. */
|
||||
public static final long MAX_BODY_BYTES = 1_000_000L;
|
||||
|
||||
@Override
|
||||
public ProviderCapabilities capabilities() {
|
||||
return new ProviderCapabilities(
|
||||
false, false, false, false, false, false, false, false, 1, 1_000_000L, Duration.ofHours(1));
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
MAX_BODY_BYTES,
|
||||
Duration.ofHours(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -130,6 +145,14 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
|
||||
NotificationJsonMapper.mapper()
|
||||
.writeValueAsString(envelope)
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
// Measured on the bytes that will be sent. The capability declared a ceiling and nothing
|
||||
// enforced it, so an oversized body was discovered by the receiver rejecting it — after the
|
||||
// request had been made, which for a webhook is after the receiver may already have acted.
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
throw new ProviderPayloadLimitException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
|
||||
}
|
||||
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("content-type", "application/json");
|
||||
@@ -139,8 +162,7 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
|
||||
WebhookSignatureStrategy.TIMESTAMP_HEADER, Long.toString(timestamp.getEpochSecond()));
|
||||
headers.put(
|
||||
WebhookSignatureStrategy.SIGNATURE_HEADER,
|
||||
signatures.sign(
|
||||
body, timestamp, secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material()));
|
||||
signatures.sign(body, timestamp, signingKey(subscription)));
|
||||
}
|
||||
|
||||
NotificationHttpRequest request =
|
||||
@@ -182,6 +204,29 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a subscription's signature is computed with.
|
||||
*
|
||||
* <p>{@code signingKeyRef} was read only to decide whether to sign at all, and the signature was
|
||||
* then computed with the platform's current callback signing key. Every trusted subscription
|
||||
* therefore shared one secret: a receiver holding its own key could verify — and forge —
|
||||
* deliveries meant for any other, and rotating one subscription's key rotated all of them.
|
||||
*
|
||||
* @throws IllegalStateException if the reference names a key that is not a callback signing key,
|
||||
* which is a configuration fault and is raised before the request is made rather than
|
||||
* producing a signature the receiver will reject
|
||||
*/
|
||||
private byte[] signingKey(WebhookSubscription subscription) {
|
||||
var key = secrets.keyById(subscription.signingKeyRef().orElseThrow());
|
||||
if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) {
|
||||
throw new IllegalStateException(
|
||||
"webhook subscription "
|
||||
+ subscription.subscriptionId()
|
||||
+ " names a key that is not a callback signing key");
|
||||
}
|
||||
return key.material();
|
||||
}
|
||||
|
||||
/**
|
||||
* The receiver's own backoff hint, when it sent a usable one.
|
||||
*
|
||||
|
||||
+25
-1
@@ -11,6 +11,10 @@ import java.util.Optional;
|
||||
* <p>{@code trusted} decides which gateway carries the call. A trusted subscription is operator
|
||||
* configured and may use platform credentials; a dynamic one comes from user input and must not
|
||||
* inherit anything, because that is how a webhook feature becomes an SSRF credential-relay.
|
||||
*
|
||||
* <p>It decides the loopback allowance for the same reason. An operator naming a local endpoint is
|
||||
* describing their own deployment; a client naming one is asking the platform to deliver a message
|
||||
* body to an interface the client cannot otherwise reach.
|
||||
*/
|
||||
public record WebhookSubscription(
|
||||
String subscriptionId, URI target, boolean trusted, Optional<String> signingKeyRef) {
|
||||
@@ -22,7 +26,27 @@ public record WebhookSubscription(
|
||||
if (subscriptionId.isBlank()) {
|
||||
throw new IllegalArgumentException("subscriptionId");
|
||||
}
|
||||
NotificationEndpoints.requireSecureOrLoopback(target, "webhook target");
|
||||
// requireExternallyRoutable, not requireSecureOrLoopback. The scheme check accepted any HTTPS
|
||||
// URL, so `https://169.254.169.254/` — the cloud metadata service — and every RFC 1918 address
|
||||
// passed. The stronger guard was written for exactly this call site and then called from
|
||||
// nowhere: it existed, its own tests were green, and the two sites it was written for kept the
|
||||
// weaker check.
|
||||
//
|
||||
// The loopback allowance is `trusted`, not a constant. It was `true` for every caller, which
|
||||
// left one case open: a client-supplied target naming `localhost` reached the loopback
|
||||
// interface. Closing it was deferred on the grounds that the allowance had to become a decision
|
||||
// the caller states and no caller existed to state it — but the decision is this record's first
|
||||
// boolean, and two lines below it already decides whether the target may inherit a platform
|
||||
// signing key. A subscription an operator configured may address a local endpoint, because the
|
||||
// operator profiles and the contract harness do exactly that. One that came from user input may
|
||||
// not, for the same reason it may not inherit credentials: it is not the deployment's own
|
||||
// address to name.
|
||||
//
|
||||
// What remains open is narrower and belongs to the guard, not here: the target is resolved once
|
||||
// at construction and re-resolved independently by the HTTP client, so a name that changes its
|
||||
// answer between the two is refused only if the first lookup already shows an internal address.
|
||||
// NTF-012, docs/reviews/2026-08-14-notification-module-code-review.md.
|
||||
NotificationEndpoints.requireExternallyRoutable(target, "webhook target", trusted);
|
||||
if (!trusted && signingKeyRef.isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a dynamic target may not be paired with a platform signing key");
|
||||
|
||||
+32
-38
@@ -4,18 +4,16 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort;
|
||||
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
|
||||
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
@@ -25,35 +23,36 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
* provider actually sent — but it routinely contains addresses and message metadata, so it is
|
||||
* encrypted and truncated rather than stored as received.
|
||||
*
|
||||
* <p>The nonce is prefixed to the ciphertext so a rotation does not need a second column, and the
|
||||
* fingerprint is keyed so that providers without an event id still get collision-resistant,
|
||||
* <p>The stored bytes are the same versioned, key-identified envelope the notification payload
|
||||
* column uses, and for the same reason. This class used to write a bare nonce and ciphertext: the
|
||||
* day the payload encryption key rotated, every retained callback became unreadable and nothing in
|
||||
* the row could say which key it had needed. A retention format whose whole justification is later
|
||||
* diagnosis has to survive the rotation that happens in between.
|
||||
*
|
||||
* <p>The fingerprint is keyed so that providers without an event id still get collision-resistant,
|
||||
* non-enumerable duplicate detection.
|
||||
*/
|
||||
public final class AesGcmCallbackPayloadProtection implements CallbackPayloadProtectionPort {
|
||||
|
||||
private static final int NONCE_BYTES = 12;
|
||||
private static final int TAG_BITS = 128;
|
||||
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final SecureRandom random;
|
||||
private final NotificationPayloadProtection payloads;
|
||||
private final int maxRetainedBytes;
|
||||
|
||||
public AesGcmCallbackPayloadProtection(SecretMaterialProvider secrets, int maxRetainedBytes) {
|
||||
this(secrets, new SecureRandom(), maxRetainedBytes);
|
||||
}
|
||||
|
||||
AesGcmCallbackPayloadProtection(
|
||||
SecretMaterialProvider secrets, SecureRandom random, int maxRetainedBytes) {
|
||||
public AesGcmCallbackPayloadProtection(
|
||||
SecretMaterialProvider secrets,
|
||||
NotificationPayloadProtection payloads,
|
||||
int maxRetainedBytes) {
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.random = Objects.requireNonNull(random, "random");
|
||||
this.payloads = Objects.requireNonNull(payloads, "payloads");
|
||||
if (maxRetainedBytes < 1) {
|
||||
throw new IllegalArgumentException("maxRetainedBytes");
|
||||
}
|
||||
if (maxRetainedBytes > MAX_PLAINTEXT_BYTES) {
|
||||
// The database check constrains the *ciphertext*, and encryption adds a 12-byte nonce and a
|
||||
// 16-byte GCM tag. Truncating the plaintext to the ciphertext bound produced a value 28 bytes
|
||||
// over it, so a callback of exactly the configured maximum was accepted by every layer above
|
||||
// and then rejected by a CHECK constraint after the provider had been told it was stored.
|
||||
// The database check constrains the *ciphertext*, and encryption adds a version byte, a key
|
||||
// id, a nonce and a GCM tag. Truncating the plaintext to the ciphertext bound produced a
|
||||
// value larger than it, so a callback of exactly the configured maximum was accepted by
|
||||
// every layer above and then rejected by a CHECK constraint after the provider had been told
|
||||
// it was stored.
|
||||
throw new IllegalArgumentException(
|
||||
"callback retention of "
|
||||
+ maxRetainedBytes
|
||||
@@ -75,8 +74,15 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro
|
||||
*/
|
||||
public static final int MAX_CIPHERTEXT_BYTES = 65_536;
|
||||
|
||||
/** The nonce and GCM tag every encryption adds. */
|
||||
public static final int ENVELOPE_OVERHEAD_BYTES = NONCE_BYTES + TAG_BITS / 8;
|
||||
/**
|
||||
* The most the envelope adds: version, key id, nonce and GCM tag.
|
||||
*
|
||||
* <p>Reserved at the largest key id the envelope allows rather than measured against the current
|
||||
* one, because a rotation to a longer id would otherwise push a body that fit yesterday past the
|
||||
* column's check constraint.
|
||||
*/
|
||||
public static final int ENVELOPE_OVERHEAD_BYTES =
|
||||
AesGcmNotificationPayloadProtection.MAX_ENVELOPE_OVERHEAD_BYTES;
|
||||
|
||||
/** The largest plaintext that still fits the column once encrypted. */
|
||||
public static final int MAX_PLAINTEXT_BYTES = MAX_CIPHERTEXT_BYTES - ENVELOPE_OVERHEAD_BYTES;
|
||||
@@ -86,22 +92,10 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro
|
||||
Objects.requireNonNull(rawBody, "rawBody");
|
||||
byte[] bounded =
|
||||
rawBody.length <= maxRetainedBytes ? rawBody : Arrays.copyOf(rawBody, maxRetainedBytes);
|
||||
byte[] nonce = new byte[NONCE_BYTES];
|
||||
random.nextBytes(nonce);
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
new SecretKeySpec(secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION).material(), "AES"),
|
||||
new GCMParameterSpec(TAG_BITS, nonce));
|
||||
byte[] ciphertext = cipher.doFinal(bounded);
|
||||
byte[] stored = new byte[nonce.length + ciphertext.length];
|
||||
System.arraycopy(nonce, 0, stored, 0, nonce.length);
|
||||
System.arraycopy(ciphertext, 0, stored, nonce.length, ciphertext.length);
|
||||
return stored;
|
||||
} catch (GeneralSecurityException failure) {
|
||||
throw new IllegalStateException("callback payload encryption failed", failure);
|
||||
}
|
||||
// Delegated rather than reimplemented so the retained callback and the retained notification
|
||||
// payload are one format with one reader. The alternative is two envelopes that drift, and the
|
||||
// one that drifts is always the one nothing reads until an incident.
|
||||
return payloads.protect(bounded);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+10
@@ -50,6 +50,16 @@ public final class AesGcmNotificationPayloadProtection implements NotificationPa
|
||||
private static final int TAG_BITS = 128;
|
||||
private static final int MAX_KEY_ID_BYTES = 255;
|
||||
|
||||
/**
|
||||
* The most this envelope can add to a plaintext.
|
||||
*
|
||||
* <p>The header is variable — a key id is one to 255 bytes — so anything that has to guarantee a
|
||||
* ciphertext fits a fixed column reserves the largest header rather than the current one. A bound
|
||||
* computed from today's key id stops holding the moment a rotation picks a longer one.
|
||||
*/
|
||||
static final int MAX_ENVELOPE_OVERHEAD_BYTES =
|
||||
2 + MAX_KEY_ID_BYTES + NONCE_BYTES + TAG_BITS / Byte.SIZE;
|
||||
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final SecureRandom random;
|
||||
|
||||
|
||||
+110
-1
@@ -5,6 +5,7 @@ import dev.caskeleton.application.notification.platform.security.SecretKeyMateri
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -14,6 +15,15 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
/**
|
||||
* Tracks which credential generation is current for each provider profile.
|
||||
*
|
||||
* <p>A rotation is a window, not an instant. Activating a new generation supersedes the previous
|
||||
* one but keeps it resolvable for a bounded drain period, because work planned against the old
|
||||
* generation is already in flight when the rotation lands: an attempt whose request may already
|
||||
* have reached the provider cannot simply be failed, and cannot be replayed either. Callers ask for
|
||||
* the generation their work was planned with — {@code ProviderProfileSnapshot} and {@code
|
||||
* DeliveryAttemptSnapshot} both carry it — so the window covers exactly that work and nothing else.
|
||||
* Past the window the old credential stops resolving, because a superseded credential that stays
|
||||
* usable indefinitely is not a rotation, it is two live credentials.
|
||||
*
|
||||
* <p>Two rotations are deliberately <em>not</em> handled here, because treating them as ordinary
|
||||
* credential swaps would silently lose data or delivery:
|
||||
*
|
||||
@@ -31,13 +41,46 @@ public final class ProviderCredentialManager {
|
||||
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final Clock clock;
|
||||
private final Duration drainWindow;
|
||||
private final Map<ProviderProfileId, CredentialGeneration> current = new ConcurrentHashMap<>();
|
||||
private final Map<ProviderProfileId, Map<Long, Draining>> draining = new ConcurrentHashMap<>();
|
||||
|
||||
public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) {
|
||||
/**
|
||||
* Creates the manager.
|
||||
*
|
||||
* @param secrets the key store
|
||||
* @param clock the clock the drain window is measured against
|
||||
* @param drainWindow how long a superseded generation keeps serving the work that started on it
|
||||
*/
|
||||
public ProviderCredentialManager(
|
||||
SecretMaterialProvider secrets, Clock clock, Duration drainWindow) {
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
Objects.requireNonNull(drainWindow, "drainWindow");
|
||||
if (drainWindow.isNegative()) {
|
||||
throw new IllegalArgumentException("drainWindow");
|
||||
}
|
||||
this.drainWindow = drainWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default drain window: long enough for an in-flight attempt, short enough to be a window.
|
||||
*/
|
||||
public static final Duration DEFAULT_DRAIN_WINDOW = Duration.ofMinutes(15);
|
||||
|
||||
/**
|
||||
* Creates the manager with the default drain window.
|
||||
*
|
||||
* @param secrets the key store
|
||||
* @param clock the clock the drain window is measured against
|
||||
*/
|
||||
public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) {
|
||||
this(secrets, clock, DEFAULT_DRAIN_WINDOW);
|
||||
}
|
||||
|
||||
/** A superseded generation and the instant it stops being usable. */
|
||||
private record Draining(CredentialGeneration generation, Instant usableUntil) {}
|
||||
|
||||
/**
|
||||
* Record the generation a profile starts on.
|
||||
*
|
||||
@@ -63,10 +106,76 @@ public final class ProviderCredentialManager {
|
||||
if (existing != null && !generation.supersedes(existing)) {
|
||||
throw new IllegalArgumentException("generation does not supersede the active one");
|
||||
}
|
||||
if (existing != null) {
|
||||
// Superseded, not deleted. An attempt that was planned against the previous generation
|
||||
// is already in flight when the rotation lands, and retiring the credential the instant
|
||||
// the new one arrives fails exactly that work — the requests nobody can replay, because
|
||||
// the provider may already have acted on them. The window bounds it: an old credential
|
||||
// that stays usable forever is not a rotation, it is two live credentials.
|
||||
retire(profileId, existing, activatedAt);
|
||||
}
|
||||
return generation.activatedAt(activatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
private void retire(
|
||||
ProviderProfileId profileId, CredentialGeneration superseded, Instant supersededAt) {
|
||||
draining
|
||||
.computeIfAbsent(profileId, id -> new ConcurrentHashMap<>())
|
||||
.put(superseded.generation(), new Draining(superseded, supersededAt.plus(drainWindow)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential material for one profile at the generation the work was planned against.
|
||||
*
|
||||
* <p>Every adapter resolved {@code activeKey(PROVIDER_CREDENTIAL)} instead: one credential for
|
||||
* every profile in the deployment, so a leak of one provider account's key was a leak of all of
|
||||
* them, and a per-profile rotation was not expressible at all. The generation is not a parameter
|
||||
* a caller invents — {@code ProviderProfileSnapshot.credentialGeneration()} and {@code
|
||||
* DeliveryAttemptSnapshot.credentialGeneration()} already carry the number the work was planned
|
||||
* with, which is what makes the drain window mean something rather than being a grace period
|
||||
* nobody claims.
|
||||
*
|
||||
* @param profileId the profile the work belongs to
|
||||
* @param generation the generation the work was planned against
|
||||
* @return the material
|
||||
* @throws IllegalStateException if the profile has no active generation, or the requested one is
|
||||
* neither current nor still inside its drain window
|
||||
*/
|
||||
public byte[] materialFor(ProviderProfileId profileId, long generation) {
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
CredentialGeneration active = current.get(profileId);
|
||||
if (active == null) {
|
||||
throw new IllegalStateException(
|
||||
"provider profile " + profileId.value() + " has no activated credential generation");
|
||||
}
|
||||
if (active.generation() == generation) {
|
||||
return material(active).material();
|
||||
}
|
||||
Draining retired = draining.getOrDefault(profileId, Map.of()).get(generation);
|
||||
if (retired == null) {
|
||||
throw new IllegalStateException(
|
||||
"provider profile "
|
||||
+ profileId.value()
|
||||
+ " has no credential generation "
|
||||
+ generation
|
||||
+ "; the active generation is "
|
||||
+ active.generation());
|
||||
}
|
||||
if (!clock.instant().isBefore(retired.usableUntil())) {
|
||||
// Dropped rather than served: past the window, work still asking for the old generation is
|
||||
// work that has been stuck long enough that using a retired credential is the larger risk.
|
||||
draining.getOrDefault(profileId, Map.of()).remove(generation);
|
||||
throw new IllegalStateException(
|
||||
"credential generation "
|
||||
+ generation
|
||||
+ " for provider profile "
|
||||
+ profileId.value()
|
||||
+ " finished draining; it is no longer usable");
|
||||
}
|
||||
return material(retired.generation()).material();
|
||||
}
|
||||
|
||||
/** Current generation of a profile. */
|
||||
public Optional<CredentialGeneration> current(ProviderProfileId profileId) {
|
||||
return Optional.ofNullable(current.get(Objects.requireNonNull(profileId, "profileId")));
|
||||
|
||||
+8
-4
@@ -10,7 +10,6 @@ import java.util.Map;
|
||||
* renderer per engine is how two implementations end up computing different digests for the same
|
||||
* template, which silently breaks the retry equality the digest exists to prove.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface NotificationTemplateEngine {
|
||||
|
||||
/**
|
||||
@@ -27,12 +26,17 @@ public interface NotificationTemplateEngine {
|
||||
* <p>The mode is required rather than inferred: the same template text is safe in a text part and
|
||||
* dangerous in an HTML one, and only the caller knows which it is filling.
|
||||
*
|
||||
* <p>Abstract, not a {@code default} that forwards to the single-argument overload. It was that
|
||||
* default, and one of the two engines never overrode it — so selecting that engine silently
|
||||
* dropped every slot to the unescaped path: a subject could carry CR/LF, a deep link could carry
|
||||
* a {@code javascript:} scheme, and plain text had its ampersands HTML-escaped on the wire. A
|
||||
* default that discards its own argument is not a fallback; it is the rule not applying, and the
|
||||
* engine that skipped it looked complete because the interface compiled.
|
||||
*
|
||||
* @param mode what the rendered value will become
|
||||
* @param source the template text
|
||||
* @param variables the values to substitute
|
||||
* @return the rendered slot
|
||||
*/
|
||||
default String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
|
||||
return render(source, variables);
|
||||
}
|
||||
String render(TemplateSlotMode mode, String source, Map<String, Object> variables);
|
||||
}
|
||||
|
||||
+3
-74
@@ -61,79 +61,8 @@ public final class PlaceholderTemplateEngine implements NotificationTemplateEngi
|
||||
|
||||
/** Escapes one substituted value for its destination. */
|
||||
private static String escape(TemplateSlotMode mode, String value) {
|
||||
return switch (mode) {
|
||||
case TEXT -> value;
|
||||
case SUBJECT -> requireSingleLine(value);
|
||||
case HTML_TEXT -> escapeHtml(value);
|
||||
case URI -> requireAllowedScheme(value);
|
||||
};
|
||||
// The rules moved to TemplateSlotPolicy so the other engine could reach them. They were private
|
||||
// here, which is why that engine had none.
|
||||
return TemplateSlotPolicy.escape(mode, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a value that would split a header.
|
||||
*
|
||||
* <p>A carriage return or newline in a subject is header injection: everything after it is read
|
||||
* as a new header by the receiving agent.
|
||||
*/
|
||||
private static String requireSingleLine(String value) {
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
if (value.charAt(index) < 0x20) {
|
||||
throw new TemplateRenderingException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.TEMPLATE_RENDERING_FAILED,
|
||||
FailureCategory.TEMPLATE_FAILURE));
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes for HTML text and attribute content.
|
||||
*
|
||||
* <p>Quotes included, because a value substituted inside an attribute can otherwise close it and
|
||||
* start an event handler — {@code " onerror="} needs no angle bracket at all.
|
||||
*/
|
||||
private static String escapeHtml(String value) {
|
||||
StringBuilder escaped = new StringBuilder(value.length() + 16);
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
switch (character) {
|
||||
case '&' -> escaped.append("&");
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
default -> escaped.append(character);
|
||||
}
|
||||
}
|
||||
return escaped.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows only schemes a notification may legitimately link to.
|
||||
*
|
||||
* <p>{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document
|
||||
* the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be
|
||||
* parsed as a URI and otherwise accepted, and parsing succeeds for all three.
|
||||
*/
|
||||
private static String requireAllowedScheme(String value) {
|
||||
String normalized = value.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
boolean allowed =
|
||||
ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":"));
|
||||
if (!allowed) {
|
||||
throw new TemplateRenderingException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schemes a rendered link may use.
|
||||
*
|
||||
* <p>HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link
|
||||
* in a notification is followed by a person who has no way to check it.
|
||||
*/
|
||||
private static final java.util.Set<String> ALLOWED_URI_SCHEMES =
|
||||
java.util.Set.of("https", "caskeleton");
|
||||
}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.template;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What each slot mode means, in one place both engines use.
|
||||
*
|
||||
* <p>These rules lived as private helpers inside {@code PlaceholderTemplateEngine}, and the other
|
||||
* engine had no equivalent — it did not override the mode-aware render at all, so selecting it
|
||||
* dropped every slot to the unescaped path. One engine enforced the policy and the other did not
|
||||
* have access to it.
|
||||
*
|
||||
* <p>Nothing here is engine-specific: a subject may not carry a control character whoever produced
|
||||
* it, and a deep link may not use {@code javascript:} whoever rendered it.
|
||||
*/
|
||||
public final class TemplateSlotPolicy {
|
||||
|
||||
/**
|
||||
* The schemes a rendered link may use.
|
||||
*
|
||||
* <p>HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link
|
||||
* in a notification is followed by a person who has no way to check it.
|
||||
*/
|
||||
private static final Set<String> ALLOWED_URI_SCHEMES = Set.of("https", "caskeleton");
|
||||
|
||||
private TemplateSlotPolicy() {}
|
||||
|
||||
/**
|
||||
* Escapes one substituted value for its destination.
|
||||
*
|
||||
* @param mode the slot being filled
|
||||
* @param value the value to escape
|
||||
* @return the escaped value
|
||||
*/
|
||||
public static String escape(TemplateSlotMode mode, String value) {
|
||||
return switch (mode) {
|
||||
case TEXT -> value;
|
||||
case SUBJECT -> requireSingleLine(value);
|
||||
case HTML_TEXT -> escapeHtml(value);
|
||||
case URI -> requireAllowedScheme(value);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a whole rendered slot, for an engine that substitutes internally.
|
||||
*
|
||||
* <p>An engine that does its own substitution cannot escape per value, so the guarantee is
|
||||
* applied to what it produced. For SUBJECT and URI that is the stronger statement: no control
|
||||
* character anywhere in the subject, and the finished link uses an allowed scheme. Escaping modes
|
||||
* are the engine's own job — asking it to render HTML and then escaping the result would escape
|
||||
* the operator's markup too.
|
||||
*
|
||||
* @param mode the slot that was filled
|
||||
* @param rendered the engine's output
|
||||
* @return the output, unchanged when it satisfies the slot
|
||||
*/
|
||||
public static String verifyRendered(TemplateSlotMode mode, String rendered) {
|
||||
return switch (mode) {
|
||||
case TEXT, HTML_TEXT -> rendered;
|
||||
case SUBJECT -> requireSingleLine(rendered);
|
||||
case URI -> requireAllowedScheme(rendered);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a value that would split a header.
|
||||
*
|
||||
* <p>A carriage return or newline in a subject is header injection: everything after it is read
|
||||
* as a new header by the receiving agent.
|
||||
*/
|
||||
private static String requireSingleLine(String value) {
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
if (value.charAt(index) < 0x20) {
|
||||
throw refuse();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes for HTML text and attribute content.
|
||||
*
|
||||
* <p>Quotes included, because a value substituted inside an attribute can otherwise close it and
|
||||
* start an event handler — {@code " onerror="} needs no angle bracket at all.
|
||||
*/
|
||||
private static String escapeHtml(String value) {
|
||||
StringBuilder escaped = new StringBuilder(value.length() + 16);
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
switch (character) {
|
||||
case '&' -> escaped.append("&");
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
default -> escaped.append(character);
|
||||
}
|
||||
}
|
||||
return escaped.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows only schemes a notification may legitimately link to.
|
||||
*
|
||||
* <p>{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document
|
||||
* the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be
|
||||
* parsed as a URI and otherwise accepted, and parsing succeeds for all three.
|
||||
*/
|
||||
private static String requireAllowedScheme(String value) {
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
boolean allowed =
|
||||
ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":"));
|
||||
if (!allowed) {
|
||||
throw refuse();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static TemplateRenderingException refuse() {
|
||||
return new TemplateRenderingException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE));
|
||||
}
|
||||
}
|
||||
+63
-9
@@ -44,6 +44,12 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
|
||||
|
||||
private final TemplateEngine engine;
|
||||
|
||||
/** Whether the constructor-supplied engine is the HTML one. */
|
||||
private final boolean htmlMode;
|
||||
|
||||
/** The text-mode engine, for every slot that is not HTML. */
|
||||
private final TemplateEngine textEngine = engineFor(TemplateMode.TEXT);
|
||||
|
||||
/** HTML-escaping engine, which is the safe default for email bodies. */
|
||||
public ThymeleafStringTemplateEngine() {
|
||||
this(TemplateMode.HTML);
|
||||
@@ -54,12 +60,25 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
|
||||
*/
|
||||
public ThymeleafStringTemplateEngine(TemplateMode mode) {
|
||||
Objects.requireNonNull(mode, "mode");
|
||||
StringTemplateResolver resolver = new StringTemplateResolver();
|
||||
resolver.setTemplateMode(mode);
|
||||
resolver.setCacheable(false);
|
||||
TemplateEngine created = new TemplateEngine();
|
||||
created.setTemplateResolver(resolver);
|
||||
this.engine = created;
|
||||
this.htmlMode = mode == TemplateMode.HTML;
|
||||
this.engine = engineFor(mode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
|
||||
Objects.requireNonNull(mode, "mode");
|
||||
// One engine per Thymeleaf template mode, chosen by what the slot is.
|
||||
//
|
||||
// This class used to implement only the mode-less overload and inherit a `default` that threw
|
||||
// the mode away, so every slot rendered under TemplateMode.HTML: a subject could carry CR/LF, a
|
||||
// deep link could carry `javascript:`, and plain text — an SMS body — had its `&` turned into
|
||||
// `&` on the wire. The interface compiled, so nothing said the policy was not applying.
|
||||
//
|
||||
// HTML_TEXT keeps the HTML engine, which is what escapes substituted values. Everything else
|
||||
// renders as text and is then checked: Thymeleaf substitutes internally, so a per-value escape
|
||||
// is not available, and verifying the finished slot is the stronger statement anyway.
|
||||
String rendered = engineFor(mode).process(source, contextFor(source, variables));
|
||||
return TemplateSlotPolicy.verifyRendered(mode, rendered);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -68,10 +87,8 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
|
||||
Objects.requireNonNull(variables, "variables");
|
||||
requireEveryReferencedVariable(source, variables);
|
||||
|
||||
Context context = new Context();
|
||||
variables.forEach(context::setVariable);
|
||||
try {
|
||||
return engine.process(source, context);
|
||||
return engine.process(source, contextFor(source, variables));
|
||||
} catch (RuntimeException failure) {
|
||||
// The message is dropped on purpose. Thymeleaf reports the offending expression, and a
|
||||
// template expression contains the variable it failed on — which for this platform is a
|
||||
@@ -105,4 +122,41 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The engine whose template mode matches the slot. */
|
||||
private TemplateEngine engineFor(TemplateSlotMode mode) {
|
||||
return mode == TemplateSlotMode.HTML_TEXT ? htmlEngine() : textEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTML engine.
|
||||
*
|
||||
* <p>The constructor-supplied engine when this instance was built for HTML, and a dedicated one
|
||||
* otherwise — a deployment that constructed the text engine still has HTML slots to render, and
|
||||
* rendering them as text would emit an operator's markup unescaped.
|
||||
*/
|
||||
private TemplateEngine htmlEngine() {
|
||||
return htmlMode ? engine : HTML_ENGINE;
|
||||
}
|
||||
|
||||
private static final TemplateEngine HTML_ENGINE = engineFor(TemplateMode.HTML);
|
||||
|
||||
private static TemplateEngine engineFor(TemplateMode mode) {
|
||||
StringTemplateResolver resolver = new StringTemplateResolver();
|
||||
resolver.setTemplateMode(mode);
|
||||
resolver.setCacheable(false);
|
||||
TemplateEngine created = new TemplateEngine();
|
||||
created.setTemplateResolver(resolver);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Builds the variable context, refusing an absent variable rather than rendering it away. */
|
||||
private Context contextFor(String source, Map<String, Object> variables) {
|
||||
Objects.requireNonNull(source, "source");
|
||||
Objects.requireNonNull(variables, "variables");
|
||||
requireEveryReferencedVariable(source, variables);
|
||||
Context context = new Context();
|
||||
variables.forEach(context::setVariable);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Which keys a deployment is actually asked for.
|
||||
*
|
||||
* <p>Startup demanded all of them, always, so an SMTP-only platform with callbacks switched off had
|
||||
* to provision and rotate a Web Push signing key, a provider credential and two callback keys that
|
||||
* nothing in that configuration could reach. Keys that exist and are never used are the ones nobody
|
||||
* notices leaking, and requiring them made the four purposes every mode genuinely needs
|
||||
* indistinguishable from the four that follow a capability.
|
||||
*/
|
||||
class NotificationSecretRequirementsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("the accept path's keys are required in every configuration")
|
||||
void theAcceptPathKeysAreAlwaysRequired() {
|
||||
var required = NotificationSecretRequirements.requiredBy(settings(false, Map.of()));
|
||||
|
||||
assertThat(required)
|
||||
.as(
|
||||
"contact points are protected, variables are encrypted at rest and provider request "
|
||||
+ "ids are hashed whenever the platform runs, providers or no providers")
|
||||
.containsExactlyInAnyOrder(
|
||||
SecretPurpose.CONTACT_ENCRYPTION,
|
||||
SecretPurpose.CONTACT_LOOKUP_HMAC,
|
||||
SecretPurpose.PAYLOAD_ENCRYPTION,
|
||||
SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an SMTP-only platform is not asked for a provider credential")
|
||||
void anSmtpOnlyPlatformIsNotAskedForAProviderCredential() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp())));
|
||||
|
||||
assertThat(required)
|
||||
.as("SMTP authenticates through spring.mail.*, so this platform holds no SMTP credential")
|
||||
.doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a disabled profile does not demand its family's keys")
|
||||
void aDisabledProfileDoesNotDemandItsKeys() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(
|
||||
settings(false, Map.of("push", disabled(webPush()))));
|
||||
|
||||
assertThat(required)
|
||||
.doesNotContain(SecretPurpose.VAPID_SIGNING)
|
||||
.doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an enabled Web Push profile demands a VAPID key and a provider credential")
|
||||
void anEnabledWebPushProfileDemandsItsKeys() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(false, Map.of("push", webPush())));
|
||||
|
||||
assertThat(required).contains(SecretPurpose.VAPID_SIGNING, SecretPurpose.PROVIDER_CREDENTIAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callbacks switched off do not demand the callback keys")
|
||||
void callbacksOffDoNotDemandTheCallbackKeys() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp())));
|
||||
|
||||
assertThat(required)
|
||||
.doesNotContain(SecretPurpose.CALLBACK_SIGNING)
|
||||
.doesNotContain(SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callbacks switched on demand both callback keys")
|
||||
void callbacksOnDemandBothCallbackKeys() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(true, Map.of("mail", smtp())));
|
||||
|
||||
assertThat(required)
|
||||
.as("verification and dedupe both run on the first callback that arrives")
|
||||
.contains(SecretPurpose.CALLBACK_SIGNING, SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a profile naming its own signing key ref demands the signing purpose")
|
||||
void aProfileNamingASigningRefDemandsTheSigningPurpose() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(false, Map.of("sms", twilio())));
|
||||
|
||||
assertThat(required)
|
||||
.as("a profile that names a signing key intends to verify signatures with it")
|
||||
.contains(SecretPurpose.CALLBACK_SIGNING);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings settings(
|
||||
boolean callbacksEnabled, Map<String, NotificationPlatformSettings.Provider> providers) {
|
||||
return new NotificationPlatformSettings(
|
||||
true,
|
||||
NotificationPlatformMode.SERVING,
|
||||
null,
|
||||
new NotificationPlatformSettings.Callbacks(callbacksEnabled, 1024, Duration.ofMinutes(5)),
|
||||
providers);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider smtp() {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
"SMTP",
|
||||
true,
|
||||
true,
|
||||
"PRODUCTION",
|
||||
"smtp-main",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Duration.ofSeconds(3),
|
||||
8,
|
||||
20);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider webPush() {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
"WEB_PUSH",
|
||||
true,
|
||||
true,
|
||||
"PRODUCTION",
|
||||
"webpush-main",
|
||||
null,
|
||||
"BPublicKey",
|
||||
null,
|
||||
Duration.ofSeconds(3),
|
||||
8,
|
||||
20);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider twilio() {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
"TWILIO",
|
||||
true,
|
||||
true,
|
||||
"PRODUCTION",
|
||||
"twilio-main",
|
||||
null,
|
||||
null,
|
||||
"twilio-callback-2026-08",
|
||||
Duration.ofSeconds(3),
|
||||
8,
|
||||
20);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider disabled(
|
||||
NotificationPlatformSettings.Provider provider) {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
provider.type(),
|
||||
false,
|
||||
provider.primaryForChannel(),
|
||||
provider.environment(),
|
||||
provider.credentialProfile(),
|
||||
provider.topic(),
|
||||
provider.vapidPublicKey(),
|
||||
provider.callbackSigningSecretRef(),
|
||||
provider.timeout(),
|
||||
provider.maxConcurrency(),
|
||||
provider.ratePerSecond());
|
||||
}
|
||||
}
|
||||
+19
@@ -301,5 +301,24 @@ class LeaseRecoveryServiceTest {
|
||||
transitions.add(Map.entry(id, state));
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RecipientDeliveryRecord> saveHeldBy(
|
||||
RecipientDeliveryRecord record,
|
||||
dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) {
|
||||
return Optional.of(save(record));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RecipientDeliveryRecord> transitionHeldBy(
|
||||
RecipientDeliveryId id,
|
||||
RecipientDeliveryState state,
|
||||
Optional<Instant> nextDispatchAt,
|
||||
dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) {
|
||||
// This fake belongs to lease *recovery*, which runs for jobs whose holder is gone; the fenced
|
||||
// variants are the dispatch path's and are not exercised here.
|
||||
transition(id, state, nextDispatchAt);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesProviderProperties;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSubscription;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The endpoint guard is reached from the places that need it.
|
||||
*
|
||||
* <p>{@code EndpointRoutabilityTest} already proves {@code requireExternallyRoutable} rejects the
|
||||
* metadata service, RFC 1918, link-local and the rest. It proved that for months while the function
|
||||
* had no caller: both sites it was written for — a webhook target and an SES endpoint — kept
|
||||
* calling {@code requireSecureOrLoopback}, which reads the scheme and nothing else. A green test on
|
||||
* a control nothing invokes is the shape this repository keeps finding, and testing the helper
|
||||
* again would not have caught it.
|
||||
*
|
||||
* <p>So these assertions go through the constructors an operator and a caller actually reach.
|
||||
*
|
||||
* <p>The loopback allowance is part of that. It was a constant {@code true} at both call sites,
|
||||
* which left a client-supplied target naming {@code localhost} accepted — the residue this finding
|
||||
* carried until the allowance became {@code trusted}, the flag the record already used to decide
|
||||
* whether the same target may inherit a platform signing key.
|
||||
*/
|
||||
class EndpointGuardCallSiteTest {
|
||||
|
||||
private static final URI METADATA = URI.create("https://169.254.169.254/latest/meta-data/");
|
||||
private static final URI PRIVATE_NETWORK = URI.create("https://10.0.0.5/hook");
|
||||
|
||||
@Test
|
||||
@DisplayName("a webhook target on the cloud metadata service is refused")
|
||||
void aWebhookTargetOnTheMetadataServiceIsRefused() {
|
||||
assertThatThrownBy(() -> new WebhookSubscription("sub-1", METADATA, false, Optional.empty()))
|
||||
.as("a client-supplied target that fetches instance credentials is the SSRF this guards")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a webhook target inside the deployment's own network is refused")
|
||||
void aWebhookTargetOnAPrivateAddressIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> new WebhookSubscription("sub-1", PRIVATE_NETWORK, true, Optional.empty()))
|
||||
.as("trusted decides credential inheritance, not whether an internal address is reachable")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a webhook target carrying userinfo is refused")
|
||||
void aWebhookTargetWithUserinfoIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new WebhookSubscription(
|
||||
"sub-1",
|
||||
URI.create("https://evil.example.com@127.0.0.1/hook"),
|
||||
true,
|
||||
Optional.empty()))
|
||||
.as("the text before '@' is what a log reader takes for the host")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an SES endpoint inside the deployment's own network is refused")
|
||||
void anSesEndpointOnAPrivateAddressIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new SesProviderProperties(
|
||||
PRIVATE_NETWORK,
|
||||
"ap-northeast-2",
|
||||
"transactional@example.com",
|
||||
Optional.empty(),
|
||||
Duration.ofSeconds(3)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a client-supplied webhook target on the loopback interface is refused")
|
||||
void aDynamicWebhookTargetOnLoopbackIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new WebhookSubscription(
|
||||
"sub-1", URI.create("https://localhost/hook"), false, Optional.empty()))
|
||||
.as(
|
||||
"the loopback allowance was a constant `true`, so the one case the guard could not "
|
||||
+ "cover was a user-supplied target that simply named localhost")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a client-supplied webhook target on 127.0.0.1 is refused")
|
||||
void aDynamicWebhookTargetOnTheLoopbackAddressIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new WebhookSubscription(
|
||||
"sub-1", URI.create("http://127.0.0.1:8080/hook"), false, Optional.empty()))
|
||||
.as("naming the address rather than the host must not be the way around the refusal")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loopback stays available, because local and contract profiles address it")
|
||||
void loopbackIsStillAccepted() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
new WebhookSubscription(
|
||||
"sub-1", URI.create("http://127.0.0.1:8080/hook"), true, Optional.empty()))
|
||||
.as(
|
||||
"this is the case the allowance exists for, and it is the reason the refusal above "
|
||||
+ "has to be conditional rather than absolute")
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
new SesProviderProperties(
|
||||
URI.create("http://localhost:4566"),
|
||||
"ap-northeast-2",
|
||||
"transactional@example.com",
|
||||
Optional.empty(),
|
||||
Duration.ofSeconds(3)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
+263
-15
@@ -1,25 +1,49 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
|
||||
import dev.caskeleton.application.notification.platform.api.content.AttachmentDisposition;
|
||||
import dev.caskeleton.application.notification.platform.api.content.AttachmentRef;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailOptions;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
|
||||
import dev.caskeleton.application.notification.platform.api.error.AttachmentIntegrityException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import jakarta.mail.Session;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -39,21 +63,8 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract {
|
||||
|
||||
@Override
|
||||
protected NotificationProviderAdapter adapter() {
|
||||
var properties =
|
||||
new SesProviderProperties(
|
||||
harness.baseUri(),
|
||||
"ap-northeast-2",
|
||||
"transactional@example.com",
|
||||
Optional.empty(),
|
||||
Duration.ofSeconds(3));
|
||||
return new SesNotificationProviderAdapter(
|
||||
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
|
||||
new SesRequestMapper(properties, new AwsSignatureV4Signer()),
|
||||
new SesFailureClassifier(),
|
||||
protector,
|
||||
SecurityFixtures.keys(),
|
||||
"AKIAEXAMPLE",
|
||||
CLOCK);
|
||||
return adapter(
|
||||
SecurityFixtures.credentials("ses-primary"), new UnconfiguredAttachmentResolver());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,4 +112,241 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract {
|
||||
assertThat(recorded.header("X-Amz-Content-Sha256")).isPresent();
|
||||
assertThat(recorded.uri().toString()).doesNotContain(ProviderFixtures.SECRET_EMAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentWithNoAttachmentStaysOnTheSimpleShape() {
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
adapter().submit(submission()).toCompletableFuture().join();
|
||||
|
||||
var content = requestContent(0);
|
||||
assertThat(content.get("Simple")).isNotNull();
|
||||
assertThat(content.get("Raw")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeclaredAttachmentIsCarriedAsRawMimeContent() {
|
||||
byte[] bytes = documentBytes();
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
var result =
|
||||
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(bytes))
|
||||
.submit(submissionWithAttachment(bytes))
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
|
||||
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
||||
var content = requestContent(0);
|
||||
assertThat(content.get("Simple"))
|
||||
.as("Simple content has no MIME part, so the declared attachment was simply not sent")
|
||||
.isNull();
|
||||
String mime =
|
||||
new String(
|
||||
Base64.getDecoder().decode(content.get("Raw").get("Data").asString()),
|
||||
StandardCharsets.UTF_8);
|
||||
assertThat(mime).contains("Contract subject");
|
||||
assertThat(mime).contains("invoice.pdf");
|
||||
assertThat(mime)
|
||||
.as("the document itself, base64 encoded as a binary part rather than merely named")
|
||||
.contains(Base64.getEncoder().encodeToString(bytes));
|
||||
assertThat(harness.received().get(0).header("Authorization").orElseThrow())
|
||||
.startsWith("AWS4-HMAC-SHA256");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAttachmentThatCannotBeResolvedIsRefusedBeforeAnythingIsSent() {
|
||||
byte[] bytes = documentBytes();
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> adapter().submit(submissionWithAttachment(bytes)).toCompletableFuture().join())
|
||||
.as("a mail that silently loses its attachment is worse than one that is not sent")
|
||||
.isInstanceOf(AttachmentUnavailableException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void attachmentBytesThatAreNotTheApprovedBytesAreRefusedBeforeAnythingIsSent() {
|
||||
byte[] approved = "invoice-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] substituted = "1nvo1ce-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(substituted))
|
||||
.submit(submissionWithAttachment(approved))
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.as("same size, different bytes: only the digest separates them")
|
||||
.isInstanceOf(AttachmentIntegrityException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMessageLargerThanSesAcceptsIsRefusedBeforeItIsSigned() {
|
||||
// Base64 transfer encoding adds a third, so this clears the limit as bytes and exceeds it as a
|
||||
// message — which is exactly the case a check against the declared attachment size misses.
|
||||
byte[] oversized = new byte[8 * 1_000_000];
|
||||
assertThat(oversized.length).isLessThan((int) SesRequestMapper.MAX_MESSAGE_BYTES);
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(oversized))
|
||||
.submit(submissionWithAttachment(oversized))
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.isInstanceOf(ProviderPayloadLimitException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoProfilesAreSignedWithTheirOwnCredential() {
|
||||
// One submission, sent twice: the SES request body is derived from content and recipient alone,
|
||||
// so anything that differs between the two signatures is the credential.
|
||||
var submission = submission();
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
adapter(
|
||||
SecurityFixtures.credentials("ses-primary", "cred-1"),
|
||||
new UnconfiguredAttachmentResolver())
|
||||
.submit(submission)
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
adapter(
|
||||
SecurityFixtures.credentials("ses-primary", "cred-2"),
|
||||
new UnconfiguredAttachmentResolver())
|
||||
.submit(submission)
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
|
||||
assertThat(harness.received().get(1).header("Authorization"))
|
||||
.as(
|
||||
"every profile signed with the platform's one current provider credential, so a leak "
|
||||
+ "of one SES account's key was a leak of every provider account")
|
||||
.isNotEqualTo(harness.received().get(0).header("Authorization"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProfileWithNoActivatedCredentialIsRefusedBeforeTheRequest() {
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(
|
||||
SecurityFixtures.credentials("some-other-profile"),
|
||||
new UnconfiguredAttachmentResolver())
|
||||
.submit(submission())
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
private SesNotificationProviderAdapter adapter(
|
||||
ProviderCredentialManager credentials, AttachmentResolver attachments) {
|
||||
var properties =
|
||||
new SesProviderProperties(
|
||||
harness.baseUri(),
|
||||
"ap-northeast-2",
|
||||
"transactional@example.com",
|
||||
Optional.empty(),
|
||||
Duration.ofSeconds(3));
|
||||
return new SesNotificationProviderAdapter(
|
||||
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
|
||||
new SesRequestMapper(
|
||||
properties,
|
||||
new AwsSignatureV4Signer(),
|
||||
new SmtpMimeMessageFactory(Session.getInstance(new Properties()))),
|
||||
new AttachmentIntegrityGuard(attachments),
|
||||
new SesFailureClassifier(),
|
||||
protector,
|
||||
credentials,
|
||||
"AKIAEXAMPLE",
|
||||
CLOCK);
|
||||
}
|
||||
|
||||
/** The {@code Content} object of a recorded request. */
|
||||
private tools.jackson.databind.JsonNode requestContent(int index) {
|
||||
return NotificationJsonMapper.mapper()
|
||||
.readTree(harness.received().get(index).bodyAsString())
|
||||
.get("Content");
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolver that hands back exactly these bytes and describes them honestly.
|
||||
*
|
||||
* <p>The digest is computed from what is returned rather than copied from the reference, so a
|
||||
* resolver that returns something other than the approved bytes is caught by the integrity guard
|
||||
* instead of being waved through by a fixture that agrees with itself.
|
||||
*/
|
||||
private static AttachmentResolver resolverReturning(byte[] bytes) {
|
||||
return (reference, context) ->
|
||||
new ResolvedAttachment(
|
||||
new ByteArrayInputStream(bytes),
|
||||
bytes.length,
|
||||
digestOf(bytes),
|
||||
reference.contentType(),
|
||||
reference.displayName());
|
||||
}
|
||||
|
||||
private ProviderSubmission submissionWithAttachment(byte[] approvedBytes) {
|
||||
return ProviderFixtures.submission(
|
||||
ProviderFixtures.profile("ses-primary", "ses", Channel.EMAIL),
|
||||
Channel.EMAIL,
|
||||
new EmailContent(
|
||||
"Contract subject",
|
||||
"Contract body",
|
||||
Optional.empty(),
|
||||
List.of(
|
||||
new AttachmentRef(
|
||||
"storage://bucket/invoice.pdf",
|
||||
"invoice.pdf",
|
||||
"application/pdf",
|
||||
approvedBytes.length,
|
||||
digestOf(approvedBytes),
|
||||
AttachmentDisposition.ATTACHMENT)),
|
||||
EmailOptions.DEFAULT),
|
||||
protector,
|
||||
EmailAddress.parse(ProviderFixtures.SECRET_EMAIL),
|
||||
Optional.of(CLOCK.instant().plus(Duration.ofHours(1))));
|
||||
}
|
||||
|
||||
/**
|
||||
* A short binary document.
|
||||
*
|
||||
* <p>Binary rather than text on purpose: MIME encodes an ASCII part as {@code 7bit} and leaves it
|
||||
* legible, which would let the assertion pass on a part that was never really encoded at all.
|
||||
*/
|
||||
private static byte[] documentBytes() {
|
||||
return new byte[] {
|
||||
'%',
|
||||
'P',
|
||||
'D',
|
||||
'F',
|
||||
'-',
|
||||
'1',
|
||||
'.',
|
||||
'7',
|
||||
'\n',
|
||||
(byte) 0x80,
|
||||
(byte) 0xC3,
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
(byte) 0xFF
|
||||
};
|
||||
}
|
||||
|
||||
private static String digestOf(byte[] bytes) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (NoSuchAlgorithmException unavailable) {
|
||||
throw new IllegalStateException("SHA-256 is required by every supported JRE", unavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* That an attached document arrives with its bytes in it.
|
||||
*
|
||||
* <p>The factory handed JavaMail the resolver's stream directly. JavaMail reads an attachment twice
|
||||
* — once to choose the part's transfer encoding, once to write the part — and the second read of an
|
||||
* already drained stream returns nothing, so the message went out announcing a filename and
|
||||
* carrying no content, and the attempt was recorded as accepted. Nothing noticed because every test
|
||||
* asserted on the outcome of the send rather than on what was sent.
|
||||
*
|
||||
* <p>Asserted on the serialised message, because that is the only place the defect was visible: the
|
||||
* part existed, its headers were right, and its body was empty.
|
||||
*/
|
||||
class SmtpAttachmentBodyTest {
|
||||
|
||||
private static final byte[] DOCUMENT = "invoice-body-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final ContactPointProtector protector =
|
||||
new AesGcmContactPointProtector(SecurityFixtures.keys());
|
||||
|
||||
private final SmtpMimeMessageFactory factory =
|
||||
new SmtpMimeMessageFactory(Session.getInstance(new Properties()));
|
||||
|
||||
@Test
|
||||
@DisplayName("an attached document is written into the message, not just named by it")
|
||||
void anAttachedDocumentCarriesItsBytes() throws Exception {
|
||||
MimeMessage message =
|
||||
factory.create(
|
||||
submission(),
|
||||
"recipient@example.test",
|
||||
"sender@example.test",
|
||||
List.of(attachment(DOCUMENT, DOCUMENT.length)));
|
||||
|
||||
assertThat(attachmentBytesOf(message))
|
||||
.as("the part announced a filename and carried nothing")
|
||||
.isEqualTo(DOCUMENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("content that is not the size the guard approved is refused")
|
||||
void contentThatIsNotTheApprovedSizeIsRefused() {
|
||||
// The integrity guard pins a size against the reference before the stream is handed over, so a
|
||||
// stream that turns out to be a different length is not the document that was approved —
|
||||
// whatever digest travelled with it.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
factory.create(
|
||||
submission(),
|
||||
"recipient@example.test",
|
||||
"sender@example.test",
|
||||
List.of(attachment(DOCUMENT, DOCUMENT.length + 1))))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
|
||||
private static ResolvedAttachment attachment(byte[] content, long declaredSize) {
|
||||
return new ResolvedAttachment(
|
||||
new ByteArrayInputStream(content),
|
||||
declaredSize,
|
||||
"sha-256:not-checked-here",
|
||||
"application/pdf",
|
||||
"invoice.pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes of the attachment part, read back the way a receiving client reads them.
|
||||
*
|
||||
* <p>Read from the serialised message rather than from the part object, because the defect was
|
||||
* exactly that the object described a part the serialisation could not fill: assertions taken
|
||||
* before {@code writeTo} saw an attachment that was about to be written empty.
|
||||
*/
|
||||
private static byte[] attachmentBytesOf(MimeMessage message) throws Exception {
|
||||
ByteArrayOutputStream wire = new ByteArrayOutputStream();
|
||||
message.writeTo(wire);
|
||||
MimeMessage received =
|
||||
new MimeMessage(
|
||||
Session.getInstance(new Properties()),
|
||||
new java.io.ByteArrayInputStream(wire.toByteArray()));
|
||||
jakarta.mail.internet.MimeMultipart parts =
|
||||
(jakarta.mail.internet.MimeMultipart) received.getContent();
|
||||
for (int index = 0; index < parts.getCount(); index++) {
|
||||
jakarta.mail.BodyPart part = parts.getBodyPart(index);
|
||||
if ("invoice.pdf".equals(part.getFileName())) {
|
||||
return part.getInputStream().readAllBytes();
|
||||
}
|
||||
}
|
||||
throw new AssertionError("the message carries no attachment part at all");
|
||||
}
|
||||
|
||||
private ProviderSubmission submission() {
|
||||
return ProviderFixtures.submission(
|
||||
ProviderFixtures.profile("smtp-primary", "smtp", Channel.EMAIL),
|
||||
Channel.EMAIL,
|
||||
ProviderFixtures.email(),
|
||||
protector,
|
||||
EmailAddress.parse(ProviderFixtures.SECRET_EMAIL),
|
||||
Optional.empty());
|
||||
}
|
||||
}
|
||||
+42
@@ -29,6 +29,7 @@ class TwilioCallbackAndProjectionTest {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
CALLBACK_URL,
|
||||
"cb-1",
|
||||
java.time.Duration.ofSeconds(3),
|
||||
java.time.Duration.ofHours(12));
|
||||
|
||||
@@ -54,6 +55,47 @@ class TwilioCallbackAndProjectionTest {
|
||||
assertThat(events.get(0).providerRequestId()).contains("SM1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoProfilesVerifyWithTheirOwnSigningKey() {
|
||||
Map<String, String> parameters =
|
||||
new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered"));
|
||||
var request = callback(parameters, signature(parameters));
|
||||
|
||||
// Signed with cb-1, presented to a profile whose reference names cb-2. Verification used the
|
||||
// platform's one current callback signing key, so every Twilio profile shared one secret and a
|
||||
// subaccount whose token leaked could forge status callbacks for any other.
|
||||
assertThat(adapterWithSigningRef("cb-2").verify(request).valid()).isFalse();
|
||||
assertThat(adapterWithSigningRef("cb-1").verify(request).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSigningRefNamingAKeyOfAnotherPurposeIsRefusedBeforeAVerdict() {
|
||||
Map<String, String> parameters =
|
||||
new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered"));
|
||||
var request = callback(parameters, signature(parameters));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(
|
||||
() -> adapterWithSigningRef("enc-1").verify(request))
|
||||
.as("a misfiled reference is a configuration fault, not a signature that never matches")
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
private TwilioCallbackAdapter adapterWithSigningRef(String signingKeyRef) {
|
||||
return new TwilioCallbackAdapter(
|
||||
new TwilioSignatureValidator(),
|
||||
new TwilioStatusNormalizer(),
|
||||
new TwilioProviderProperties(
|
||||
java.net.URI.create("https://api.twilio.example"),
|
||||
"AC123",
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
CALLBACK_URL,
|
||||
signingKeyRef,
|
||||
java.time.Duration.ofSeconds(3),
|
||||
java.time.Duration.ofHours(12)),
|
||||
SecurityFixtures.keys());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidSignatureIsRejected() {
|
||||
Map<String, String> parameters =
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ class TwilioCallbackContractTest extends CallbackContract {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
CALLBACK_URL,
|
||||
"cb-1",
|
||||
Duration.ofSeconds(3),
|
||||
Duration.ofHours(12));
|
||||
|
||||
|
||||
+2
-1
@@ -39,6 +39,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary",
|
||||
"cb-1",
|
||||
Duration.ofSeconds(3),
|
||||
Duration.ofHours(12));
|
||||
}
|
||||
@@ -50,7 +51,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract {
|
||||
new TwilioRequestMapper(properties()),
|
||||
new TwilioFailureClassifier(),
|
||||
protector,
|
||||
SecurityFixtures.keys());
|
||||
SecurityFixtures.credentials("twilio-primary"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+192
-13
@@ -1,23 +1,37 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.SettingsSecretMaterialProvider;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
|
||||
import dev.caskeleton.application.notification.platform.api.content.InAppContent;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -37,14 +51,52 @@ class WebhookNotificationProviderAdapterTest {
|
||||
|
||||
@Test
|
||||
void dynamicTargetNeverInheritsTrustedCredentials() {
|
||||
harness.respondWith(200, "{}", Map.of());
|
||||
// Both gateway arguments used to be the same instance, so "the request carried no
|
||||
// Authorization header" was a property of the test's own wiring rather than of the adapter:
|
||||
// there was nothing in the graph that could have added one, and the assertion would have held
|
||||
// just as well for a trusted subscription. What actually decides credential inheritance is
|
||||
// which of the two gateways the adapter hands the request to, and that is only observable when
|
||||
// they are distinguishable.
|
||||
//
|
||||
// The target is a routable literal rather than the local harness because a client-supplied
|
||||
// target may no longer name the loopback interface (NTF-012). A literal address also keeps the
|
||||
// constructor's resolution check off DNS.
|
||||
RecordingGateway trusted = new RecordingGateway();
|
||||
RecordingGateway dynamic = new RecordingGateway();
|
||||
WebhookSubscription subscription =
|
||||
new WebhookSubscription("sub-2", ROUTABLE_TARGET, false, Optional.empty());
|
||||
|
||||
adapter(dynamicSubscription()).submit(submission()).toCompletableFuture().join();
|
||||
adapter(trusted, dynamic, submission -> subscription)
|
||||
.submit(submission())
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
|
||||
var recorded = harness.received().get(0);
|
||||
assertThat(recorded.header("Authorization")).isEmpty();
|
||||
assertThat(recorded.header("Cookie")).isEmpty();
|
||||
assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER)).isEmpty();
|
||||
assertThat(trusted.exchanged)
|
||||
.as("a client-supplied target must not reach the gateway that carries platform credentials")
|
||||
.isEmpty();
|
||||
assertThat(dynamic.exchanged).hasSize(1);
|
||||
assertThat(dynamic.exchanged.get(0).headers())
|
||||
.doesNotContainKeys("authorization", "cookie", WebhookSignatureStrategy.SIGNATURE_HEADER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedTargetReachesTheCredentialedGateway() {
|
||||
// The counterpart, so the assertion above cannot pass by the adapter never reaching either
|
||||
// gateway, and so the routing rule is watched working in both directions.
|
||||
RecordingGateway trusted = new RecordingGateway();
|
||||
RecordingGateway dynamic = new RecordingGateway();
|
||||
WebhookSubscription subscription =
|
||||
new WebhookSubscription("sub-1", ROUTABLE_TARGET, true, Optional.of("cb-1"));
|
||||
|
||||
adapter(trusted, dynamic, submission -> subscription)
|
||||
.submit(submission())
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
|
||||
assertThat(dynamic.exchanged).isEmpty();
|
||||
assertThat(trusted.exchanged).hasSize(1);
|
||||
assertThat(trusted.exchanged.get(0).headers())
|
||||
.containsKey(WebhookSignatureStrategy.SIGNATURE_HEADER);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,33 +130,160 @@ class WebhookNotificationProviderAdapterTest {
|
||||
assertThat(result.failure().orElseThrow().nativeCode().orElseThrow().length()).isLessThan(1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoSubscriptionsWithDifferentKeyRefsAreSignedDifferently() {
|
||||
// One submission for both deliveries: the attempt id is part of the body, so two submissions
|
||||
// would differ in what was signed and the signatures would differ whatever key was used.
|
||||
var submission = submission();
|
||||
harness.respondWith(200, "{}", Map.of());
|
||||
adapter(trustedSubscription("cb-1")).submit(submission).toCompletableFuture().join();
|
||||
harness.respondWith(200, "{}", Map.of());
|
||||
adapter(trustedSubscription("cb-2")).submit(submission).toCompletableFuture().join();
|
||||
|
||||
var first = harness.received().get(0).header(WebhookSignatureStrategy.SIGNATURE_HEADER);
|
||||
var second = harness.received().get(1).header(WebhookSignatureStrategy.SIGNATURE_HEADER);
|
||||
|
||||
assertThat(first).isPresent();
|
||||
assertThat(second)
|
||||
.as(
|
||||
"signingKeyRef only decided whether to sign; the signature came from one platform key, "
|
||||
+ "so every trusted receiver could verify and forge every other receiver's webhook")
|
||||
.isPresent()
|
||||
.isNotEqualTo(first);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKeyRefThatIsNotACallbackSigningKeyIsRefusedBeforeTheRequest() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(trustedSubscription("cred-1"))
|
||||
.submit(submission())
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodyOverTheDeclaredCeilingIsRefusedBeforeTheRequest() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(trustedSubscription("cb-1"))
|
||||
.submit(oversizedSubmission())
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.isInstanceOf(ProviderPayloadLimitException.class);
|
||||
|
||||
assertThat(harness.received())
|
||||
.as("the capability declared a ceiling and nothing measured the bytes against it")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* A target outside every range the endpoint guard refuses, written as a literal.
|
||||
*
|
||||
* <p>TEST-NET-3, which is reserved for documentation and routes nowhere — and being a literal, it
|
||||
* is never looked up, so the guard's resolution step does not make these tests depend on DNS.
|
||||
*/
|
||||
private static final URI ROUTABLE_TARGET = URI.create("https://203.0.113.10/hook");
|
||||
|
||||
/** Records what it was asked to send and answers 200, so nothing is dialled. */
|
||||
private static final class RecordingGateway implements NotificationHttpGateway {
|
||||
|
||||
private final List<NotificationHttpRequest> exchanged = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public NotificationHttpResponse exchange(NotificationHttpRequest request) {
|
||||
exchanged.add(request);
|
||||
return new NotificationHttpResponse(
|
||||
200, Map.of(), "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private WebhookNotificationProviderAdapter adapter(
|
||||
NotificationHttpGateway trustedGateway,
|
||||
NotificationHttpGateway dynamicGateway,
|
||||
Function<ProviderSubmission, WebhookSubscription> subscriptions) {
|
||||
return new WebhookNotificationProviderAdapter(
|
||||
trustedGateway,
|
||||
dynamicGateway,
|
||||
new WebhookSignatureStrategy(),
|
||||
keys(),
|
||||
subscriptions,
|
||||
Duration.ofSeconds(3),
|
||||
CLOCK);
|
||||
}
|
||||
|
||||
private WebhookNotificationProviderAdapter adapter(WebhookSubscription subscription) {
|
||||
var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2));
|
||||
return new WebhookNotificationProviderAdapter(
|
||||
gateway,
|
||||
gateway,
|
||||
new WebhookSignatureStrategy(),
|
||||
SecurityFixtures.keys(),
|
||||
keys(),
|
||||
submission -> subscription,
|
||||
Duration.ofSeconds(3),
|
||||
CLOCK);
|
||||
}
|
||||
|
||||
private WebhookSubscription trustedSubscription() {
|
||||
return new WebhookSubscription(
|
||||
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign"));
|
||||
/**
|
||||
* Two callback signing keys, so a per-subscription reference has something to distinguish.
|
||||
*
|
||||
* <p>{@code cred-1} is present as well: a reference naming a key issued for another purpose is a
|
||||
* configuration fault this adapter has to catch rather than sign with.
|
||||
*/
|
||||
private static SecretMaterialProvider keys() {
|
||||
return new SettingsSecretMaterialProvider(
|
||||
Map.of(
|
||||
SecretPurpose.CALLBACK_SIGNING,
|
||||
new SecretKeyMaterial("cb-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x33)),
|
||||
SecretPurpose.CONTACT_ENCRYPTION,
|
||||
new SecretKeyMaterial("enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11)),
|
||||
SecretPurpose.CONTACT_LOOKUP_HMAC,
|
||||
new SecretKeyMaterial("mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22))),
|
||||
Map.of(
|
||||
"cb-2",
|
||||
new SecretKeyMaterial("cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34)),
|
||||
"cred-1",
|
||||
new SecretKeyMaterial(
|
||||
"cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44))));
|
||||
}
|
||||
|
||||
private WebhookSubscription dynamicSubscription() {
|
||||
private static byte[] filled(byte value) {
|
||||
byte[] material = new byte[32];
|
||||
java.util.Arrays.fill(material, value);
|
||||
return material;
|
||||
}
|
||||
|
||||
private WebhookSubscription trustedSubscription() {
|
||||
return trustedSubscription("cb-1");
|
||||
}
|
||||
|
||||
private WebhookSubscription trustedSubscription(String signingKeyRef) {
|
||||
return new WebhookSubscription(
|
||||
"sub-2", harness.baseUri().resolve("/hook"), false, Optional.empty());
|
||||
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of(signingKeyRef));
|
||||
}
|
||||
|
||||
private ProviderSubmission submission() {
|
||||
return submission(ProviderFixtures.webhook());
|
||||
}
|
||||
|
||||
private ProviderSubmission oversizedSubmission() {
|
||||
return submission(
|
||||
new InAppContent(
|
||||
"Order shipped",
|
||||
"x".repeat((int) WebhookNotificationProviderAdapter.MAX_BODY_BYTES + 1),
|
||||
Optional.empty(),
|
||||
java.util.List.of(),
|
||||
"order"));
|
||||
}
|
||||
|
||||
private ProviderSubmission submission(InAppContent content) {
|
||||
return ProviderFixtures.submission(
|
||||
ProviderFixtures.profile("webhook-main", "webhook", Channel.WEBHOOK),
|
||||
Channel.WEBHOOK,
|
||||
ProviderFixtures.webhook(),
|
||||
content,
|
||||
protector,
|
||||
new InAppRecipientRef("user-1"),
|
||||
Optional.empty());
|
||||
|
||||
+9
-4
@@ -49,7 +49,7 @@ class CallbackPayloadBoundTest {
|
||||
() ->
|
||||
new AesGcmCallbackPayloadProtection(
|
||||
SecurityFixtures.keys(),
|
||||
new java.security.SecureRandom(),
|
||||
payloads(),
|
||||
AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES))
|
||||
.as("this is exactly the configuration that produced 65,564 bytes of ciphertext")
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
@@ -62,7 +62,7 @@ class CallbackPayloadBoundTest {
|
||||
AesGcmCallbackPayloadProtection protection =
|
||||
new AesGcmCallbackPayloadProtection(
|
||||
SecurityFixtures.keys(),
|
||||
new java.security.SecureRandom(),
|
||||
payloads(),
|
||||
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
|
||||
|
||||
byte[] stored =
|
||||
@@ -78,7 +78,7 @@ class CallbackPayloadBoundTest {
|
||||
AesGcmCallbackPayloadProtection protection =
|
||||
new AesGcmCallbackPayloadProtection(
|
||||
SecurityFixtures.keys(),
|
||||
new java.security.SecureRandom(),
|
||||
payloads(),
|
||||
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
|
||||
|
||||
byte[] stored =
|
||||
@@ -96,8 +96,13 @@ class CallbackPayloadBoundTest {
|
||||
() ->
|
||||
new AesGcmCallbackPayloadProtection(
|
||||
SecurityFixtures.keys(),
|
||||
new java.security.SecureRandom(),
|
||||
payloads(),
|
||||
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private static AesGcmNotificationPayloadProtection payloads() {
|
||||
return new AesGcmNotificationPayloadProtection(
|
||||
SecurityFixtures.keys(), new java.security.SecureRandom());
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What a retained callback is worth after the payload key rotates.
|
||||
*
|
||||
* <p>The retained raw body exists for one reason: a normalization bug is only diagnosable against
|
||||
* what the provider actually sent. The stored bytes were a nonce and a ciphertext and nothing else,
|
||||
* so the first rotation of the payload encryption key turned every retained callback into bytes
|
||||
* that no key could be matched to — the retention outlived the key but not the ability to name it,
|
||||
* which is the same as not retaining it.
|
||||
*/
|
||||
class CallbackPayloadRotationTest {
|
||||
|
||||
private static final byte[] RAW =
|
||||
"{\"MessageId\":\"m-1\",\"eventType\":\"Delivery\"}".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
@DisplayName("a callback retained before a rotation is still readable after it")
|
||||
void aCallbackRetainedBeforeARotationIsStillReadableAfterIt() {
|
||||
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
|
||||
|
||||
byte[] revealed =
|
||||
new AesGcmNotificationPayloadProtection(afterRotation(), new SecureRandom()).reveal(stored);
|
||||
|
||||
assertThat(revealed)
|
||||
.as("the envelope names the key it used, so the retired key can be asked for by id")
|
||||
.isEqualTo(RAW);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the retained bytes name the key that encrypted them")
|
||||
void theRetainedBytesNameTheKeyThatEncryptedThem() {
|
||||
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
|
||||
|
||||
int keyIdLength = Byte.toUnsignedInt(stored[1]);
|
||||
String keyId = new String(stored, 2, keyIdLength, StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(stored[0])
|
||||
.as("a format that cannot say which format it is can only change by rewriting every row")
|
||||
.isEqualTo(AesGcmNotificationPayloadProtection.VERSION);
|
||||
assertThat(keyId).isEqualTo("payload-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the retained bytes are not the payload")
|
||||
void theRetainedBytesAreNotThePayload() {
|
||||
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
|
||||
|
||||
assertThat(new String(stored, StandardCharsets.UTF_8)).doesNotContain("MessageId");
|
||||
}
|
||||
|
||||
private static AesGcmCallbackPayloadProtection protection(SecretMaterialProvider keys) {
|
||||
return new AesGcmCallbackPayloadProtection(
|
||||
keys,
|
||||
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()),
|
||||
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
|
||||
}
|
||||
|
||||
/** The key store as it stood when the callback arrived. */
|
||||
private static SecretMaterialProvider beforeRotation() {
|
||||
return new SettingsSecretMaterialProvider(
|
||||
Map.of(
|
||||
SecretPurpose.PAYLOAD_ENCRYPTION,
|
||||
payloadKey("payload-1", (byte) 0x55),
|
||||
SecretPurpose.CALLBACK_FINGERPRINT_HMAC,
|
||||
new SecretKeyMaterial(
|
||||
"fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88))),
|
||||
Map.of());
|
||||
}
|
||||
|
||||
/** The key store after the payload key was replaced and the old one retired. */
|
||||
private static SecretMaterialProvider afterRotation() {
|
||||
return new SettingsSecretMaterialProvider(
|
||||
Map.of(SecretPurpose.PAYLOAD_ENCRYPTION, payloadKey("payload-2", (byte) 0x56)),
|
||||
Map.of("payload-1", payloadKey("payload-1", (byte) 0x55)));
|
||||
}
|
||||
|
||||
private static SecretKeyMaterial payloadKey(String keyId, byte fill) {
|
||||
return new SecretKeyMaterial(keyId, SecretPurpose.PAYLOAD_ENCRYPTION, filled(fill));
|
||||
}
|
||||
|
||||
private static byte[] filled(byte value) {
|
||||
byte[] material = new byte[32];
|
||||
Arrays.fill(material, value);
|
||||
return material;
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* A rotation as a window rather than an instant.
|
||||
*
|
||||
* <p>A superseded generation used to be overwritten outright. The attempts planned against it are
|
||||
* already in flight when the rotation lands, and they are the ones that cannot simply be failed and
|
||||
* cannot be replayed either — the provider may already have acted on the request. Retiring the
|
||||
* credential at the moment the new one arrives fails exactly that work.
|
||||
*
|
||||
* <p>The opposite mistake is keeping it forever, which is not a rotation but two live credentials.
|
||||
* The window is what makes the retirement real, so it is asserted from both ends.
|
||||
*/
|
||||
class CredentialDrainWindowTest {
|
||||
|
||||
private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary");
|
||||
private static final ProviderProfileId OTHER = new ProviderProfileId("ses-secondary");
|
||||
private static final Instant START = Instant.parse("2026-08-19T00:00:00Z");
|
||||
private static final Duration WINDOW = Duration.ofMinutes(15);
|
||||
|
||||
private final MovableClock clock = new MovableClock(START);
|
||||
private final ProviderCredentialManager manager =
|
||||
new ProviderCredentialManager(credentialKeys(), clock, WINDOW);
|
||||
|
||||
@Test
|
||||
@DisplayName("a rotation switches new work to the new generation")
|
||||
void aRotationSwitchesNewWorkToTheNewGeneration() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
|
||||
|
||||
assertThat(manager.current(PROFILE).orElseThrow().generation()).isEqualTo(2);
|
||||
assertThat(manager.materialFor(PROFILE, 2)).isEqualTo(filled((byte) 0x44));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("in-flight work keeps the generation it was planned against")
|
||||
void inFlightWorkKeepsTheGenerationItWasPlannedAgainst() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
|
||||
|
||||
clock.advance(WINDOW.minusSeconds(1));
|
||||
|
||||
assertThat(manager.materialFor(PROFILE, 1))
|
||||
.as("an attempt the provider may already have acted on cannot be failed or replayed")
|
||||
.isEqualTo(filled((byte) 0x33));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the retired generation stops resolving once the window closes")
|
||||
void theRetiredGenerationStopsResolvingOnceTheWindowCloses() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
|
||||
|
||||
clock.advance(WINDOW);
|
||||
|
||||
assertThatThrownBy(() -> manager.materialFor(PROFILE, 1))
|
||||
.as("a superseded credential that never expires is not a rotation, it is two live keys")
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("finished draining");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a generation that was never activated is refused, drained or not")
|
||||
void aGenerationThatWasNeverActivatedIsRefused() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
|
||||
assertThatThrownBy(() -> manager.materialFor(PROFILE, 7))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("no credential generation 7");
|
||||
assertThatThrownBy(() -> manager.materialFor(OTHER, 1))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("no activated credential generation");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two profiles resolve different material")
|
||||
void twoProfilesResolveDifferentMaterial() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
manager.activate(CredentialGeneration.candidate(OTHER, 1, "cred-2"));
|
||||
|
||||
assertThat(manager.materialFor(PROFILE, 1))
|
||||
.as("one platform-wide provider credential made a leak of one account a leak of all")
|
||||
.isNotEqualTo(manager.materialFor(OTHER, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a handle naming a key of another purpose never becomes a credential")
|
||||
void aHandleNamingAKeyOfAnotherPurposeIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "callback-1")))
|
||||
.as("refused at the rotation, so no dispatch can ever resolve it")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> manager.materialFor(PROFILE, 1))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a negative drain window is refused")
|
||||
void aNegativeDrainWindowIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> new ProviderCredentialManager(credentialKeys(), clock, Duration.ofMinutes(-1)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/** Two provider credentials and one key of another purpose, to prove the separation holds. */
|
||||
private static SecretMaterialProvider credentialKeys() {
|
||||
return new SettingsSecretMaterialProvider(
|
||||
Map.of(
|
||||
SecretPurpose.PROVIDER_CREDENTIAL,
|
||||
new SecretKeyMaterial("cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x33)),
|
||||
SecretPurpose.CALLBACK_SIGNING,
|
||||
new SecretKeyMaterial(
|
||||
"callback-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x55))),
|
||||
Map.of(
|
||||
"cred-2",
|
||||
new SecretKeyMaterial(
|
||||
"cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44))));
|
||||
}
|
||||
|
||||
private static byte[] filled(byte value) {
|
||||
byte[] material = new byte[32];
|
||||
Arrays.fill(material, value);
|
||||
return material;
|
||||
}
|
||||
|
||||
/** A clock the test moves, so the window is asserted rather than waited out. */
|
||||
private static final class MovableClock extends Clock {
|
||||
|
||||
private Instant now;
|
||||
|
||||
private MovableClock(Instant now) {
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
private void advance(Duration by) {
|
||||
now = now.plus(by);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-1
@@ -35,7 +35,54 @@ public final class SecurityFixtures {
|
||||
"fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88, 32)),
|
||||
SecretPurpose.VAPID_SIGNING,
|
||||
new SecretKeyMaterial("vapid-1", SecretPurpose.VAPID_SIGNING, filled((byte) 0x66, 32))),
|
||||
Map.of());
|
||||
// A second provider credential, so a fixture can give two profiles genuinely different
|
||||
// material rather than asserting per-profile binding against one shared key.
|
||||
Map.of(
|
||||
"cred-2",
|
||||
new SecretKeyMaterial(
|
||||
"cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x45, 32)),
|
||||
"cb-2",
|
||||
new SecretKeyMaterial(
|
||||
"cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34, 32))));
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential manager holding generation 1 of each named profile.
|
||||
*
|
||||
* <p>Adapters resolve a profile's credential rather than the platform's one current provider key,
|
||||
* so a contract test has to say which profile it is speaking for — which is the point: a fixture
|
||||
* that could not name a profile was a fixture proving a shape the platform no longer has.
|
||||
*
|
||||
* @param profileIds the profiles to activate
|
||||
* @return the manager
|
||||
*/
|
||||
public static ProviderCredentialManager credentials(String... profileIds) {
|
||||
var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC());
|
||||
for (String profileId : profileIds) {
|
||||
activate(manager, profileId, "cred-1");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential manager holding generation 1 of one profile, backed by a named key.
|
||||
*
|
||||
* @param profileId the profile to activate
|
||||
* @param keyId which provider credential it is bound to
|
||||
* @return the manager
|
||||
*/
|
||||
public static ProviderCredentialManager credentials(String profileId, String keyId) {
|
||||
var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC());
|
||||
activate(manager, profileId, keyId);
|
||||
return manager;
|
||||
}
|
||||
|
||||
private static void activate(ProviderCredentialManager manager, String profileId, String keyId) {
|
||||
manager.activate(
|
||||
CredentialGeneration.candidate(
|
||||
new dev.caskeleton.application.notification.platform.api.ProviderProfileId(profileId),
|
||||
1,
|
||||
keyId));
|
||||
}
|
||||
|
||||
public static SecretMaterialProvider keysWithSameMaterial() {
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.template;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.thymeleaf.templatemode.TemplateMode;
|
||||
|
||||
/**
|
||||
* Every engine honours the slot mode, not just the one the tests happened to instantiate.
|
||||
*
|
||||
* <p>{@code SlotAwareRenderingTest} constructs {@code PlaceholderTemplateEngine} and only that, and
|
||||
* the mode-aware method was a {@code default} that forwarded to the unescaped overload. The
|
||||
* Thymeleaf engine never overrode it, so with {@code template.engine=thymeleaf} — a supported,
|
||||
* documented value — a subject could carry CR/LF and a deep link could carry a {@code javascript:}
|
||||
* scheme. Nothing failed, because the interface compiled and the one engine under test was the one
|
||||
* that implemented the rule.
|
||||
*
|
||||
* <p>Parameterized over the engines for that reason: a rule that only holds for the implementation
|
||||
* somebody remembered to test is not a rule the platform has.
|
||||
*/
|
||||
class BothEnginesHonourSlotModeTest {
|
||||
|
||||
static Stream<Arguments> engines() {
|
||||
return Stream.of(
|
||||
Arguments.of("placeholder", new PlaceholderTemplateEngine()),
|
||||
Arguments.of("thymeleaf-html", new ThymeleafStringTemplateEngine(TemplateMode.HTML)),
|
||||
Arguments.of("thymeleaf-text", new ThymeleafStringTemplateEngine(TemplateMode.TEXT)));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("engines")
|
||||
@DisplayName("a newline in a subject is refused, whichever engine renders it")
|
||||
void aSubjectMayNotCarryAControlCharacter(String name, NotificationTemplateEngine engine) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
engine.render(
|
||||
TemplateSlotMode.SUBJECT,
|
||||
subjectTemplate(name),
|
||||
Map.of("code", "123\r\nBcc: attacker@example.com")))
|
||||
.as("everything after a CR/LF is read as a new header by the receiving agent")
|
||||
.isInstanceOf(TemplateRenderingException.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("engines")
|
||||
@DisplayName("a javascript: deep link is refused, whichever engine renders it")
|
||||
void aDeepLinkMayNotUseAScriptScheme(String name, NotificationTemplateEngine engine) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
engine.render(
|
||||
TemplateSlotMode.URI,
|
||||
linkTemplate(name),
|
||||
Map.of("link", "javascript:alert(1)")))
|
||||
.isInstanceOf(TemplateRenderingException.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("engines")
|
||||
@DisplayName("an https deep link is accepted, so the rule is a filter and not a refusal")
|
||||
void anHttpsDeepLinkIsAccepted(String name, NotificationTemplateEngine engine) {
|
||||
// Without this, the assertion above is satisfied by an engine that refuses every URI slot.
|
||||
assertThat(
|
||||
engine.render(
|
||||
TemplateSlotMode.URI, linkTemplate(name), Map.of("link", "https://example.com/a")))
|
||||
.contains("https://example.com/a");
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("engines")
|
||||
@DisplayName("markup in an HTML slot is escaped, whichever engine renders it")
|
||||
void markupInAnHtmlSlotIsEscaped(String name, NotificationTemplateEngine engine) {
|
||||
assertThat(
|
||||
engine.render(
|
||||
TemplateSlotMode.HTML_TEXT,
|
||||
bodyTemplate(name),
|
||||
Map.of("name", "<script>x</script>")))
|
||||
.as("a substituted value must not become markup")
|
||||
.doesNotContain("<script>");
|
||||
}
|
||||
|
||||
/** Each engine's own placeholder syntax; the rule under test is the escaping, not the syntax. */
|
||||
private static String subjectTemplate(String engine) {
|
||||
return engine.startsWith("thymeleaf") ? "code [[${code}]]" : "code {{code}}";
|
||||
}
|
||||
|
||||
private static String linkTemplate(String engine) {
|
||||
return engine.startsWith("thymeleaf") ? "[[${link}]]" : "{{link}}";
|
||||
}
|
||||
|
||||
private static String bodyTemplate(String engine) {
|
||||
return engine.startsWith("thymeleaf") ? "hello [[${name}]]" : "hello {{name}}";
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -107,10 +107,18 @@ public final class ContractAdapters {
|
||||
var adapter =
|
||||
new SesNotificationProviderAdapter(
|
||||
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
|
||||
new SesRequestMapper(properties, new AwsSignatureV4Signer()),
|
||||
new SesRequestMapper(
|
||||
properties,
|
||||
new AwsSignatureV4Signer(),
|
||||
new dev.caskeleton.adapter.outbound.notification.platform.provider.smtp
|
||||
.SmtpMimeMessageFactory(
|
||||
jakarta.mail.Session.getInstance(new java.util.Properties()))),
|
||||
new dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard(
|
||||
new dev.caskeleton.adapter.outbound.notification.platform.provider
|
||||
.UnconfiguredAttachmentResolver()),
|
||||
new SesFailureClassifier(),
|
||||
protector,
|
||||
SecurityFixtures.keys(),
|
||||
SecurityFixtures.credentials("ses-primary"),
|
||||
"AKIAEXAMPLE",
|
||||
CLOCK);
|
||||
return new Case(
|
||||
@@ -133,6 +141,7 @@ public final class ContractAdapters {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary",
|
||||
"cb-1",
|
||||
Duration.ofSeconds(3),
|
||||
Duration.ofHours(12));
|
||||
var adapter =
|
||||
@@ -141,7 +150,7 @@ public final class ContractAdapters {
|
||||
new TwilioRequestMapper(properties),
|
||||
new TwilioFailureClassifier(),
|
||||
protector,
|
||||
SecurityFixtures.keys());
|
||||
SecurityFixtures.credentials("twilio-primary"));
|
||||
return new Case(
|
||||
"twilio",
|
||||
adapter,
|
||||
@@ -218,9 +227,11 @@ public final class ContractAdapters {
|
||||
|
||||
private static Case webhook(ProviderFaultHarness harness, ContactPointProtector protector) {
|
||||
var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2));
|
||||
// The id of a real callback signing key, because a subscription is now signed with the key its
|
||||
// reference names rather than with whatever the platform's current one happens to be.
|
||||
var subscription =
|
||||
new WebhookSubscription(
|
||||
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign"));
|
||||
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("cb-1"));
|
||||
var adapter =
|
||||
new WebhookNotificationProviderAdapter(
|
||||
gateway,
|
||||
|
||||
@@ -8,36 +8,11 @@
|
||||
// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import.
|
||||
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
|
||||
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
|
||||
sourceSets {
|
||||
objectStorageMinioContractTest {
|
||||
java.srcDir 'src/objectStorageMinioContractTest/java'
|
||||
resources.srcDir 'src/objectStorageMinioContractTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
objectStorageMinioFaultTest {
|
||||
java.srcDir 'src/objectStorageMinioFaultTest/java'
|
||||
resources.srcDir 'src/objectStorageMinioFaultTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
objectStorageAwsQualificationTest {
|
||||
java.srcDir 'src/objectStorageAwsQualificationTest/java'
|
||||
resources.srcDir 'src/objectStorageAwsQualificationTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
objectStorageMinioContractTestImplementation.extendsFrom testImplementation
|
||||
objectStorageMinioContractTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
objectStorageMinioFaultTestImplementation.extendsFrom testImplementation
|
||||
objectStorageMinioFaultTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
objectStorageAwsQualificationTestImplementation.extendsFrom testImplementation
|
||||
objectStorageAwsQualificationTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
strictTestLanes {
|
||||
sourceSet('objectStorageMinioContractTest') { compilesAgainst 'main', 'test' }
|
||||
sourceSet('objectStorageMinioFaultTest') { compilesAgainst 'main', 'test' }
|
||||
sourceSet('objectStorageAwsQualificationTest') { compilesAgainst 'main', 'test' }
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
|
||||
@@ -5,41 +5,20 @@
|
||||
// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral).
|
||||
// The JPA relational persistence platform (docs/superpowers/specs/2026-08-11-jpa-persistence-
|
||||
// platform-design.md) models itself as 18 Stable library modules. This repository's fail-closed
|
||||
// 19-leaf registry outranks that layout, so those modules are packages here and
|
||||
// module registry outranks that layout, so those modules are packages here and
|
||||
// JpaModuleBoundaryTest enforces the design's module dependency table. The full mapping is in
|
||||
// docs/jpa/repository-adaptation.md.
|
||||
//
|
||||
// The testkit is its own source set rather than part of `test` because more than one lane consumes
|
||||
// it and because a source set whose dependencies are declared only on the test configurations gives
|
||||
// the design's "no production module depends on the testkit" guarantee without a new Gradle project.
|
||||
sourceSets {
|
||||
postgresqlIntegrationTest {
|
||||
java.setSrcDirs(['src/postgresqlIntegrationTest/java'])
|
||||
resources.setSrcDirs(['src/postgresqlIntegrationTest/resources'])
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
strictTestLanes {
|
||||
sourceSet('postgresqlIntegrationTest') {
|
||||
compilesAgainst 'main'
|
||||
inherits 'implementation', 'compileOnly', 'runtimeOnly', 'annotationProcessor'
|
||||
}
|
||||
testkit {
|
||||
java.srcDir 'src/testkit/java'
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
jpaPlatformPerformanceTest {
|
||||
java.srcDir 'src/jpaPlatformPerformanceTest/java'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
postgresqlIntegrationTestImplementation.extendsFrom testImplementation
|
||||
postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly
|
||||
postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor
|
||||
testkitImplementation.extendsFrom testImplementation
|
||||
testkitRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
jpaPlatformPerformanceTestImplementation.extendsFrom testImplementation
|
||||
jpaPlatformPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
sourceSet('testkit') { compilesAgainst 'main' }
|
||||
sourceSet('jpaPlatformPerformanceTest') { compilesAgainst 'main', 'testkit' }
|
||||
}
|
||||
|
||||
// The testkit as a consumable artifact.
|
||||
@@ -49,30 +28,9 @@ configurations {
|
||||
// "domain must not depend on Hibernate" were verified as library code and applied to nothing. The
|
||||
// composition root is the only place that can see every runtime leaf at once, so it is where the
|
||||
// production suite belongs — and it needs the rules.
|
||||
tasks.register('testkitJar', Jar) {
|
||||
archiveClassifier = 'testkit'
|
||||
from sourceSets.testkit.output
|
||||
}
|
||||
|
||||
configurations {
|
||||
jpaTestkit {
|
||||
canBeConsumed = true
|
||||
canBeResolved = false
|
||||
}
|
||||
}
|
||||
|
||||
artifacts {
|
||||
jpaTestkit(tasks.named('testkitJar', Jar))
|
||||
}
|
||||
|
||||
// Every test lane compiles and runs against the testkit.
|
||||
sourceSets.test {
|
||||
compileClasspath += sourceSets.testkit.output
|
||||
runtimeClasspath += sourceSets.testkit.output
|
||||
}
|
||||
sourceSets.postgresqlIntegrationTest {
|
||||
compileClasspath += sourceSets.testkit.output
|
||||
runtimeClasspath += sourceSets.testkit.output
|
||||
testkitPublisher {
|
||||
consumedBy 'test', 'postgresqlIntegrationTest'
|
||||
publishAs 'jpaTestkit'
|
||||
}
|
||||
|
||||
ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine'
|
||||
@@ -313,10 +271,12 @@ def jpaPlatformSecurityTest = registerJpaPlatformLane(
|
||||
// The pool behaviour contract. Named for what it does.
|
||||
//
|
||||
// It was `jpaPlatformPerformanceTest`, described as certifying pool and REQUIRES_NEW pressure, and
|
||||
// gated by `performance.assertions.enabled` — which defaulted to false everywhere, including in the
|
||||
// nightly workflow that set it explicitly to false. So the release gate depended on a lane whose
|
||||
// only threshold assertion was that thresholds were not being asserted. "Certified" described a
|
||||
// run in which no latency or throughput bound was ever compared to anything.
|
||||
// gated behind a boolean that defaulted to off everywhere it appeared — in this file, and in the
|
||||
// nightly workflow that set it explicitly to off. So the release gate depended on a lane whose only
|
||||
// threshold assertion was that thresholds were not being asserted, and "certified" described a run
|
||||
// in which no latency or throughput bound was ever compared to anything. The property is gone; its
|
||||
// name is deliberately not repeated here, because a name in a comment is the next thing somebody
|
||||
// tries to set.
|
||||
//
|
||||
// What the lane genuinely verifies is a behaviour contract: a REQUIRES_NEW depth of one needs two
|
||||
// connections per concurrent thread, a saturated pool reports its pending count, and a caller waits
|
||||
@@ -349,4 +309,48 @@ tasks.register('jpaPlatformReleaseGate') {
|
||||
dependsOn jpaPlatformPoolContractTest
|
||||
}
|
||||
|
||||
// The unit lane reads three files that are not Java sources: the release registry and its two
|
||||
// renderings. Without declaring them, Gradle calls the lane up-to-date after a registry demotion or
|
||||
// a workflow edit — so the drift check that exists to catch exactly that edit never runs on it.
|
||||
// The unit lane reads files that are not Java sources: the release registry, its two renderings,
|
||||
// and the documents that describe the pool lane. Without declaring them, Gradle calls the lane
|
||||
// up-to-date after a registry demotion or a workflow edit — so the drift checks that exist to catch
|
||||
// exactly those edits never run on them.
|
||||
tasks.named('test') {
|
||||
inputs.file(rootProject.file('config/jpa/release-registry.json'))
|
||||
inputs.file(new File(rootProject.projectDir.parentFile, 'docs/jpa/support-matrix.md'))
|
||||
inputs.file(new File(rootProject.projectDir.parentFile, 'docs/jpa/repository-adaptation.md'))
|
||||
// The whole directory, not the two named workflows: the Experimental-major check asks whether
|
||||
// *some* lane records that major as its target, so adding or deleting any workflow can change
|
||||
// its answer. Naming files here would leave the lane that matters outside the up-to-date check.
|
||||
inputs.dir(new File(rootProject.projectDir.parentFile, '.github/workflows'))
|
||||
inputs.file(file('build.gradle'))
|
||||
inputs.dir(file('src/jpaPlatformPerformanceTest/java'))
|
||||
}
|
||||
|
||||
// verifyJpaApiSurface — every public type this leaf exposes is a committed decision.
|
||||
//
|
||||
// The GraphQL and Mongo leaves already carried this; the largest of the three did not, so the one
|
||||
// public surface with the most adopters was the one nothing had an opinion about. The convention
|
||||
// plugin is opt-in per leaf, and opting in was simply never done here.
|
||||
//
|
||||
// This is a record, not a budget, and the distinction matters. A snapshot shrinks nothing on its
|
||||
// own: the GraphQL surface grew from 373 types to 398 while under one, each addition approved and
|
||||
// none refused. What the baseline buys is that growth is visible in review at the moment it
|
||||
// happens and that the number is available to argue with — not that the number cannot rise. A
|
||||
// ceiling the approval flag cannot lift is a separate decision nobody has taken yet.
|
||||
// `api` is the surface an adopter is meant to reach; everything else here is a candidate to become
|
||||
// internal at that point.
|
||||
apiSurface {
|
||||
label = 'Jpa'
|
||||
baseline = rootProject.file('../docs/architecture/jpa-api-surface.txt')
|
||||
description = 'JPA persistence leaf public API surface — every public top-level type in src/main/java.'
|
||||
rationale = [
|
||||
'A public type in a single-jar leaf is reachable from every adopter\'s code, so',
|
||||
'additions are reviewed rather than discovered. `api` is the intended external',
|
||||
'surface; the rest is implementation that has not been moved under an internal',
|
||||
'root yet.',
|
||||
]
|
||||
}
|
||||
|
||||
apply from: rootProject.file('gradle/jpa-evidence.gradle')
|
||||
|
||||
+10
-8
@@ -8,16 +8,18 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Pool pressure certification (design §38).
|
||||
* The pool behaviour a {@code REQUIRES_NEW} deployment has to satisfy (design §38).
|
||||
*
|
||||
* <p>The lane reports rather than asserts unless {@code performance.assertions.enabled} is set,
|
||||
* because the numbers depend on the machine. A shared CI runner producing a red build for a
|
||||
* threshold it never had the resources to meet teaches people to ignore the lane.
|
||||
* <p>This lane certifies nothing, and used to say it did. It was described as pool pressure
|
||||
* certification and gated behind a flag that defaulted to false everywhere it appeared, including
|
||||
* the nightly job that set it to false explicitly — so "certified" named a run in which no latency
|
||||
* or throughput bound was ever compared to anything.
|
||||
*
|
||||
* <p>What is asserted unconditionally is the arithmetic the pool has to satisfy. With {@code
|
||||
* REQUIRES_NEW}, a thread holds the outer transaction's connection while acquiring a second one, so
|
||||
* a pool sized for the thread count alone deadlocks with every connection held by a thread waiting
|
||||
* for another connection.
|
||||
* <p>What is asserted is the arithmetic, which is true on any machine and therefore needs no flag.
|
||||
* With {@code REQUIRES_NEW}, a thread holds the outer transaction's connection while acquiring a
|
||||
* second one, so a pool sized for the thread count alone deadlocks with every connection held by a
|
||||
* thread waiting for another connection. A real performance gate needs a dedicated runner, warmup
|
||||
* and sample counts and recorded thresholds; when that exists it belongs in a lane of its own.
|
||||
*/
|
||||
class PoolPressureContractTest {
|
||||
|
||||
|
||||
+23
@@ -1,5 +1,7 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.rls;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeature;
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeatureGate;
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.util.Objects;
|
||||
@@ -31,6 +33,27 @@ public final class RlsTenantSessionBinder {
|
||||
/** Transaction-local binding; the {@code true} argument is what scopes it to the transaction. */
|
||||
private static final String BIND_SQL = "select set_config('app.tenant_id', ?, true)";
|
||||
|
||||
/**
|
||||
* The only way to obtain one.
|
||||
*
|
||||
* <p>The gate is asked before anything is constructed, so a deployment that never set the flag
|
||||
* cannot end up holding an instance. Taking the gate as a parameter rather than consulting a
|
||||
* static makes the requirement part of the signature: a caller cannot forget an argument the
|
||||
* compiler insists on.
|
||||
*
|
||||
* @param gate the experimental consent gate
|
||||
* @param flags the deployment's experimental flags
|
||||
* @throws IllegalStateException naming the property that must be set
|
||||
*/
|
||||
public static RlsTenantSessionBinder enabledBy(
|
||||
ExperimentalFeatureGate gate, java.util.Map<String, Boolean> flags) {
|
||||
Objects.requireNonNull(gate, "gate")
|
||||
.requireEnabled(ExperimentalFeature.MULTITENANCY_RLS, flags);
|
||||
return new RlsTenantSessionBinder();
|
||||
}
|
||||
|
||||
RlsTenantSessionBinder() {}
|
||||
|
||||
/** Binds {@code tenant} for the remainder of the current transaction. */
|
||||
public void bind(EntityManager entityManager, TenantId tenant) {
|
||||
Objects.requireNonNull(entityManager, "entityManager");
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ final class FileEntityMapper {
|
||||
Optional.ofNullable(entity.getLeaseOwner()),
|
||||
Optional.ofNullable(entity.getLeaseToken()),
|
||||
Optional.ofNullable(entity.getLeaseUntil()),
|
||||
!entity.isActive(),
|
||||
entity.getVersion(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt());
|
||||
|
||||
+17
@@ -118,6 +118,23 @@ public class JpaCleanupQueue implements CleanupQueue {
|
||||
return List.copyOf(claimed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int reclaimExpiredClaims(Instant now, int limit) {
|
||||
if (limit < 1) {
|
||||
throw new IllegalArgumentException("limit must be positive");
|
||||
}
|
||||
int reclaimed = 0;
|
||||
for (CleanupItemEntity abandoned : items.findExpiredClaims(now, Limit.of(limit))) {
|
||||
// Matched on the token that was read, so the reaper that loses the race changes nothing —
|
||||
// and the worker that eventually wakes up finds its own token gone and settles nothing.
|
||||
reclaimed +=
|
||||
items.reclaimExpiredClaim(
|
||||
abandoned.getCleanupId(), abandoned.getClaimToken(), clock.instant());
|
||||
heldClaims.remove(abandoned.getCleanupId());
|
||||
}
|
||||
return reclaimed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markDone(CleanupItem item) {
|
||||
Instant now = clock.instant();
|
||||
|
||||
+14
@@ -28,6 +28,10 @@ import org.springframework.stereotype.Repository;
|
||||
* <p>A lease is granted only when none is held or the held one expired, and an offset commit
|
||||
* additionally requires the exact token plus the expected offset. This is the only correctness
|
||||
* mechanism for multi-instance appends; no filesystem or NFS lock participates.
|
||||
*
|
||||
* <p>The session's own lifecycle is the second half of it. A lease answers who is writing now and
|
||||
* says nothing about whether the upload is still one anybody may write to, so cleanup could delete
|
||||
* the staged bytes of an upload a writer was about to take a lease on.
|
||||
*/
|
||||
@Repository
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
@@ -110,6 +114,16 @@ public class JpaUploadSessionStore implements UploadSessionStore {
|
||||
leases.releaseLease(uploadId.value(), lease.token(), clock.instant());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean terminate(UploadId uploadId) {
|
||||
return leases.terminate(uploadId.value(), clock.instant()) == 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean claimForCleanup(UploadId uploadId, Instant now) {
|
||||
return leases.claimForCleanup(uploadId.value(), now) == 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UploadSession> findExpired(Instant cutoff, int limit) {
|
||||
return sessions.findExpired(cutoff, Limit.of(limit)).stream()
|
||||
|
||||
+10
@@ -148,6 +148,16 @@ public class CleanupItemEntity {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/** The token of the claim currently held, or {@code null} when the item is unclaimed. */
|
||||
public UUID getClaimToken() {
|
||||
return claimToken;
|
||||
}
|
||||
|
||||
/** When the held claim stops being valid, or {@code null} when there is none. */
|
||||
public Instant getLeaseUntil() {
|
||||
return leaseUntil;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
+24
@@ -16,6 +16,11 @@ import org.hibernate.type.SqlTypes;
|
||||
* <p>The lease columns are the multi-instance single-writer mechanism. They are only ever changed
|
||||
* by the conditional statements in {@code UploadLeaseRepository}, so a paused writer whose lease
|
||||
* expired cannot advance {@code committed_offset}.
|
||||
*
|
||||
* <p>{@code lifecycle_state} is the other half of that mechanism. A lease says who is writing right
|
||||
* now; it says nothing about whether the upload is still one anybody may write to, and cleanup used
|
||||
* to delete staging bytes on the strength of the lease alone. A cancelled upload therefore looked
|
||||
* exactly like an idle live one.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "fs_upload_session")
|
||||
@@ -52,6 +57,9 @@ public class UploadSessionEntity {
|
||||
@Column(name = "lease_until")
|
||||
private Instant leaseUntil;
|
||||
|
||||
@Column(name = "lifecycle_state", nullable = false, length = 16)
|
||||
private String lifecycleState;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private long version;
|
||||
@@ -62,6 +70,12 @@ public class UploadSessionEntity {
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
/** The state of an upload that may still be written to. */
|
||||
public static final String ACTIVE = "ACTIVE";
|
||||
|
||||
/** The state of an upload nobody may write to again. */
|
||||
public static final String TERMINAL = "TERMINAL";
|
||||
|
||||
protected UploadSessionEntity() {}
|
||||
|
||||
/** Builds a fresh upload resource at offset zero and without a lease. */
|
||||
@@ -78,6 +92,7 @@ public class UploadSessionEntity {
|
||||
this.expectedLength = expectedLength;
|
||||
this.committedOffset = 0;
|
||||
this.expiresAt = expiresAt;
|
||||
this.lifecycleState = ACTIVE;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = createdAt;
|
||||
}
|
||||
@@ -118,6 +133,15 @@ public class UploadSessionEntity {
|
||||
return leaseUntil;
|
||||
}
|
||||
|
||||
public String getLifecycleState() {
|
||||
return lifecycleState;
|
||||
}
|
||||
|
||||
/** Whether the upload may still be written to. */
|
||||
public boolean isActive() {
|
||||
return ACTIVE.equals(lifecycleState);
|
||||
}
|
||||
|
||||
public long getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
+32
@@ -99,4 +99,36 @@ public interface FileserverCleanupRepository extends JpaRepository<CleanupItemEn
|
||||
order by c.leaseUntil
|
||||
""")
|
||||
List<CleanupItemEntity> findExpiredClaims(@Param("now") Instant now, Limit limit);
|
||||
|
||||
/**
|
||||
* Returns one expired claim to the queue.
|
||||
*
|
||||
* <p>Matched on the token the reaper read, so two reapers racing for the same abandoned item
|
||||
* cannot both hand it back — and a worker that wakes up still holding that token settles nothing,
|
||||
* because {@code recordAttempt} matches on it too.
|
||||
*
|
||||
* <p>The item comes back as FAILED rather than PENDING, and its attempt counter advances. An item
|
||||
* whose worker dies every time is then bounded by the same retry budget as one that fails
|
||||
* outright, instead of being reclaimed forever.
|
||||
*
|
||||
* @return 1 when this caller reclaimed it, 0 when someone else already had
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
update CleanupItemEntity c
|
||||
set c.status = 'FAILED',
|
||||
c.attempt = c.attempt + 1,
|
||||
c.nextAttemptAt = :now,
|
||||
c.lastErrorCode = 'CLAIM_LEASE_EXPIRED',
|
||||
c.claimOwner = null,
|
||||
c.claimToken = null,
|
||||
c.leaseUntil = null,
|
||||
c.updatedAt = :now
|
||||
where c.cleanupId = :cleanupId
|
||||
and c.claimToken = :token
|
||||
and c.status = 'IN_PROGRESS'
|
||||
""")
|
||||
int reclaimExpiredClaim(
|
||||
@Param("cleanupId") UUID cleanupId, @Param("token") UUID token, @Param("now") Instant now);
|
||||
}
|
||||
|
||||
+59
@@ -14,6 +14,11 @@ import org.springframework.data.repository.query.Param;
|
||||
* <p>A lease is granted only when none is held or the held one has expired, and an offset commit
|
||||
* additionally requires the exact lease token and the expected offset. Correctness never depends on
|
||||
* a filesystem or NFS lock.
|
||||
*
|
||||
* <p>Every writer statement also requires the session to be {@code ACTIVE}. Without that clause a
|
||||
* cancelled upload still handed out leases: acquire looked at the upload's expiry and the held
|
||||
* lease and at no fact about the upload's own lifecycle, so a writer could take a lease on bytes
|
||||
* that cleanup had already been asked to delete, and the two then raced for the same object.
|
||||
*/
|
||||
public interface UploadLeaseRepository extends Repository<UploadSessionEntity, UUID> {
|
||||
|
||||
@@ -29,6 +34,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
|
||||
where s.uploadId = :uploadId
|
||||
and s.version = :expectedVersion
|
||||
and s.expiresAt > :now
|
||||
and s.lifecycleState = 'ACTIVE'
|
||||
and (s.leaseUntil is null or s.leaseUntil <= :now)
|
||||
""")
|
||||
int acquireLease(
|
||||
@@ -49,6 +55,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
|
||||
where s.uploadId = :uploadId
|
||||
and s.leaseToken = :token
|
||||
and s.leaseUntil > :now
|
||||
and s.lifecycleState = 'ACTIVE'
|
||||
""")
|
||||
int renewLease(
|
||||
@Param("uploadId") UUID uploadId,
|
||||
@@ -66,6 +73,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
|
||||
where s.uploadId = :uploadId
|
||||
and s.leaseToken = :token
|
||||
and s.leaseUntil > :now
|
||||
and s.lifecycleState = 'ACTIVE'
|
||||
and s.committedOffset = :expectedOffset
|
||||
""")
|
||||
int commitOffset(
|
||||
@@ -89,4 +97,55 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
|
||||
""")
|
||||
int releaseLease(
|
||||
@Param("uploadId") UUID uploadId, @Param("token") UUID token, @Param("now") Instant now);
|
||||
|
||||
/**
|
||||
* Ends the upload's writable life.
|
||||
*
|
||||
* <p>Run inside the transaction that decides the upload is over — a cancel, a failed verification
|
||||
* — so the queued staging cleanup and the fact that no writer may touch those bytes again commit
|
||||
* together. Enqueuing the cleanup alone left a window in which a writer could still acquire a
|
||||
* lease on the object about to be deleted.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
update UploadSessionEntity s
|
||||
set s.lifecycleState = 'TERMINAL',
|
||||
s.version = s.version + 1,
|
||||
s.updatedAt = :now
|
||||
where s.uploadId = :uploadId
|
||||
and s.lifecycleState = 'ACTIVE'
|
||||
""")
|
||||
int terminate(@Param("uploadId") UUID uploadId, @Param("now") Instant now);
|
||||
|
||||
/**
|
||||
* Claims a terminal session whose writer lease has lapsed, for physical deletion.
|
||||
*
|
||||
* <p>A claim rather than a check. Cleanup used to read the session, see no live lease and then
|
||||
* delete — and a writer acquiring the lease in between turned that delete into the removal of an
|
||||
* active upload's bytes. Here the database decides: the lease is cleared in the same statement
|
||||
* that proves it was not held, and a writer arriving afterwards is refused by the {@code ACTIVE}
|
||||
* clause on acquire.
|
||||
*
|
||||
* <p>Idempotent on purpose. A worker that deleted the object and died before settling runs this
|
||||
* again on the retry, matches the already-cleared lease, and settles the same item once more
|
||||
* rather than being stuck.
|
||||
*
|
||||
* @return 1 when the caller may delete the staged bytes, 0 when a writer still holds the lease or
|
||||
* the session is not terminal
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
update UploadSessionEntity s
|
||||
set s.leaseOwner = null,
|
||||
s.leaseToken = null,
|
||||
s.leaseUntil = null,
|
||||
s.version = s.version + 1,
|
||||
s.updatedAt = :now
|
||||
where s.uploadId = :uploadId
|
||||
and s.lifecycleState = 'TERMINAL'
|
||||
and (s.leaseUntil is null or s.leaseUntil <= :now)
|
||||
""")
|
||||
int claimForCleanup(@Param("uploadId") UUID uploadId, @Param("now") Instant now);
|
||||
}
|
||||
|
||||
+38
@@ -3,6 +3,9 @@ package dev.caskeleton.adapter.outbound.persistence.notification.platform;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/*
|
||||
* Top-level, not nested inside a holder class. Spring Data does not discover nested repository
|
||||
@@ -25,4 +28,39 @@ public interface DeduplicationClaimJpaRepository
|
||||
String category,
|
||||
String dedupKey,
|
||||
long windowBucket);
|
||||
|
||||
/**
|
||||
* Claims the deduplication window, or reports that someone else holds it.
|
||||
*
|
||||
* <p>{@code ON CONFLICT DO NOTHING} rather than an insert that may raise — the same shape, and
|
||||
* for the same reason, as {@code NotificationRequestJpaRepository.claimIdempotencyKey}. A unique
|
||||
* violation leaves the PostgreSQL transaction aborted, and the loser's whole job is to then read
|
||||
* the winner, which is a statement the database refuses until rollback. The previous
|
||||
* insert-then-catch-then-select could only work on a database that does not abort on constraint
|
||||
* violation; on PostgreSQL the recovery read was unreachable and the loser saw the follow-up
|
||||
* failure rather than the winner's notification id.
|
||||
*
|
||||
* @return 1 when this caller claimed the window, 0 when another already had
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
value =
|
||||
"INSERT INTO notification_deduplication_claim ("
|
||||
+ " id, tenant_id, recipient_ref, category, dedup_key, window_bucket,"
|
||||
+ " notification_id, created_at"
|
||||
+ ") VALUES ("
|
||||
+ " :id, :tenantId, :recipientRef, :category, :dedupKey, :windowBucket,"
|
||||
+ " :notificationId, :createdAt"
|
||||
+ ") ON CONFLICT (tenant_id, recipient_ref, category, dedup_key, window_bucket)"
|
||||
+ " DO NOTHING",
|
||||
nativeQuery = true)
|
||||
int claimWindow(
|
||||
@Param("id") UUID id,
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("recipientRef") String recipientRef,
|
||||
@Param("category") String category,
|
||||
@Param("dedupKey") String dedupKey,
|
||||
@Param("windowBucket") long windowBucket,
|
||||
@Param("notificationId") UUID notificationId,
|
||||
@Param("createdAt") java.time.Instant createdAt);
|
||||
}
|
||||
|
||||
+14
@@ -17,6 +17,7 @@ import dev.caskeleton.application.notification.platform.callback.DeliveryAttempt
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryProjection;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryProjectionStorePort;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptRecord;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort;
|
||||
@@ -135,6 +136,19 @@ public final class JpaDeliveryAttemptStore
|
||||
.flatMap(this::toSnapshot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DeliveryAttemptSnapshot> byProviderRequestIdHash(
|
||||
ProviderProfileId profileId, ProviderRequestIdHash providerRequestIdHash) {
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
Objects.requireNonNull(providerRequestIdHash, "providerRequestIdHash");
|
||||
// The same index, reached from the side that already has the digest. A stored event never has
|
||||
// the raw identifier to hash, so the overload above cannot serve a sweep.
|
||||
return attempts
|
||||
.findByProviderProfileIdAndProviderRequestIdHash(
|
||||
profileId.value(), providerRequestIdHash.value())
|
||||
.flatMap(this::toSnapshot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeliveryProjection load(DeliveryAttemptId attemptId) {
|
||||
// Every fact comes back from the row. This used to read the outcome columns and then hand back
|
||||
|
||||
+35
-25
@@ -18,7 +18,6 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
/** Preference, consent and deduplication persistence. */
|
||||
public final class JpaPolicyStores {
|
||||
@@ -166,32 +165,43 @@ public final class JpaPolicyStores {
|
||||
|
||||
// Insert first and let the unique constraint decide. A read-then-write would let two
|
||||
// concurrent submissions both conclude they are the first.
|
||||
try {
|
||||
claims.saveAndFlush(
|
||||
new DeduplicationClaimEntity(
|
||||
ids.nextId(),
|
||||
recipient.tenantId().value(),
|
||||
recipient.recipientRef(),
|
||||
recipient.category(),
|
||||
dedupKey,
|
||||
windowBucket,
|
||||
candidate.value(),
|
||||
clock.instant()));
|
||||
//
|
||||
// `ON CONFLICT DO NOTHING`, not insert-and-catch. The catch branch read the winner in the
|
||||
// same
|
||||
// transaction the unique violation had just aborted, so on PostgreSQL the loser never reached
|
||||
// it: the recovery SELECT is refused until rollback, and the caller saw that refusal instead
|
||||
// of the winner's notification id. The request-key claim in this same package was rewritten
|
||||
// for exactly this reason and says so; this one was left behind.
|
||||
int claimed =
|
||||
claims.claimWindow(
|
||||
ids.nextId(),
|
||||
recipient.tenantId().value(),
|
||||
recipient.recipientRef(),
|
||||
recipient.category(),
|
||||
dedupKey,
|
||||
windowBucket,
|
||||
candidate.value(),
|
||||
clock.instant());
|
||||
if (claimed == 1) {
|
||||
return DeduplicationResult.first(candidate);
|
||||
} catch (DataIntegrityViolationException alreadyClaimed) {
|
||||
return claims
|
||||
.findByTenantIdAndRecipientRefAndCategoryAndDedupKeyAndWindowBucket(
|
||||
recipient.tenantId().value(),
|
||||
recipient.recipientRef(),
|
||||
recipient.category(),
|
||||
dedupKey,
|
||||
windowBucket)
|
||||
.map(
|
||||
existing ->
|
||||
DeduplicationResult.duplicateOf(
|
||||
candidate, new NotificationId(existing.notificationId())))
|
||||
.orElseGet(() -> DeduplicationResult.first(candidate));
|
||||
}
|
||||
// Zero rows means somebody else holds the window, and the transaction is still usable, so the
|
||||
// winner can actually be read.
|
||||
return claims
|
||||
.findByTenantIdAndRecipientRefAndCategoryAndDedupKeyAndWindowBucket(
|
||||
recipient.tenantId().value(),
|
||||
recipient.recipientRef(),
|
||||
recipient.category(),
|
||||
dedupKey,
|
||||
windowBucket)
|
||||
.map(
|
||||
existing ->
|
||||
DeduplicationResult.duplicateOf(
|
||||
candidate, new NotificationId(existing.notificationId())))
|
||||
// The row was claimed and is already gone — a window that expired between the two
|
||||
// statements. Treating this caller as first is the safe reading: it will be deduplicated
|
||||
// by the next window if the duplicate is real.
|
||||
.orElseGet(() -> DeduplicationResult.first(candidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -12,6 +12,7 @@ import dev.caskeleton.application.notification.platform.callback.ProviderEventLe
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventSource;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash;
|
||||
import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
|
||||
@@ -207,6 +208,15 @@ public final class JpaProviderEventLedger implements ProviderEventLedger {
|
||||
return List.copyOf(bound);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean bindAttempt(ProviderEventRecordId eventId, DeliveryAttemptId attemptId) {
|
||||
Objects.requireNonNull(eventId, "eventId");
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
// The same compare-and-set the dispatch-side bind uses: whoever matched the event first keeps
|
||||
// it, and a later sweep neither rebinds it nor reports having done so.
|
||||
return events.bindAttemptIfUnbound(eventId.value(), attemptId.value()) == 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
@@ -287,6 +297,10 @@ public final class JpaProviderEventLedger implements ProviderEventLedger {
|
||||
Optional.ofNullable(entity.providerOccurredAt()),
|
||||
Map.copyOf(decoded)),
|
||||
Optional.ofNullable(entity.attemptId()).map(DeliveryAttemptId::new),
|
||||
// The hash goes back out even though the raw id does not. It is the only thing a stored
|
||||
// event has to find its attempt with, and dropping it left a callback that arrived before
|
||||
// its attempt unmatchable by every later sweep.
|
||||
Optional.ofNullable(entity.providerRequestIdHash()).map(ProviderRequestIdHash::new),
|
||||
ProviderEventSource.valueOf(entity.eventSource()),
|
||||
entity.signatureVerified(),
|
||||
entity.receivedAt(),
|
||||
|
||||
+64
@@ -4,6 +4,7 @@ import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientLease;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
@@ -63,4 +64,67 @@ public final class JpaRecipientDeliveryStore implements RecipientDeliveryStorePo
|
||||
entity.transition(state.name(), nextDispatchAt.orElse(null), clock.instant());
|
||||
return mapper.toRecord(recipients.saveAndFlush(entity));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RecipientDeliveryRecord> saveHeldBy(
|
||||
RecipientDeliveryRecord record, RecipientLease lease) {
|
||||
Objects.requireNonNull(record, "record");
|
||||
Objects.requireNonNull(lease, "lease");
|
||||
// One conditional statement rather than findById → mutate → saveAndFlush. The read-modify-write
|
||||
// cannot express "only if I still hold this": by the time the entity is loaded the lease may
|
||||
// already belong to somebody else, and JPA's version column detects a concurrent edit rather
|
||||
// than a superseded writer.
|
||||
int written =
|
||||
recipients.saveProjectionHeldBy(
|
||||
record.id().value(),
|
||||
lease.owner(),
|
||||
lease.fence(),
|
||||
record.state().name(),
|
||||
record.submissionOutcome().name(),
|
||||
record.deliveryOutcome().name(),
|
||||
record.evidenceLevel().name(),
|
||||
record.ambiguousAttemptExists(),
|
||||
record.duplicateRisk(),
|
||||
record.routeCursor(),
|
||||
record.attemptCount(),
|
||||
record.lastFailureCategory().orElse(null),
|
||||
record.nextDispatchAt().orElse(null),
|
||||
clock.instant());
|
||||
return reread(written, record.id());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RecipientDeliveryRecord> transitionHeldBy(
|
||||
RecipientDeliveryId id,
|
||||
RecipientDeliveryState state,
|
||||
Optional<Instant> nextDispatchAt,
|
||||
RecipientLease lease) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(state, "state");
|
||||
Objects.requireNonNull(nextDispatchAt, "nextDispatchAt");
|
||||
Objects.requireNonNull(lease, "lease");
|
||||
int written =
|
||||
recipients.transitionHeldBy(
|
||||
id.value(),
|
||||
lease.owner(),
|
||||
lease.fence(),
|
||||
state.name(),
|
||||
nextDispatchAt.orElse(null),
|
||||
clock.instant());
|
||||
return reread(written, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the row back after a conditional write, or reports that the lease was superseded.
|
||||
*
|
||||
* <p>The statements carry {@code clearAutomatically}, because a native update bypasses the
|
||||
* persistence context and this re-read would otherwise be served from the first-level cache with
|
||||
* the values the update just replaced.
|
||||
*/
|
||||
private Optional<RecipientDeliveryRecord> reread(int written, RecipientDeliveryId id) {
|
||||
if (written == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return recipients.findById(id.value()).map(mapper::toRecord);
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -53,6 +53,65 @@ public interface RecipientDeliveryJpaRepository
|
||||
@Query(value = RecipientClaimSql.EXPIRE_OVERDUE, nativeQuery = true)
|
||||
int expireOverdue(@Param("now") Instant now, @Param("batchSize") int batchSize);
|
||||
|
||||
/**
|
||||
* Writes a completion projection, and only for the holder that still owns the job.
|
||||
*
|
||||
* <p>The counterpart of {@link #renewLease}, for the write that happens *after* the provider
|
||||
* call. Everything before the submission is database work a new holder would simply redo; the
|
||||
* outcome is not — writing it under a superseded lease reports one worker's result on another
|
||||
* worker's attempt, and the two need not agree about whether the notification was sent.
|
||||
*
|
||||
* <p>Conditioned on owner and fence for the same reason as the renew: two incarnations of one
|
||||
* configured worker id share the owner string, so the fence is what distinguishes them.
|
||||
*/
|
||||
// clearAutomatically, because the caller re-reads this row immediately. A native update bypasses
|
||||
// the persistence context, so without it the re-read is served from the first-level cache with
|
||||
// the values this statement just replaced.
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
value =
|
||||
"UPDATE notification_recipient_delivery "
|
||||
+ "SET delivery_state = :deliveryState, submission_outcome = :submissionOutcome, "
|
||||
+ "delivery_outcome = :deliveryOutcome, evidence_level = :evidenceLevel, "
|
||||
+ "ambiguous_attempt_exists = :ambiguousAttemptExists, duplicate_risk = :duplicateRisk, "
|
||||
+ "route_cursor = :routeCursor, attempt_count = :attemptCount, "
|
||||
+ "last_failure_category = :lastFailureCategory, next_dispatch_at = :nextDispatchAt, "
|
||||
+ "version = version + 1, updated_at = :now "
|
||||
+ "WHERE id = :id AND lease_owner = :owner AND lease_fence = :fence",
|
||||
nativeQuery = true)
|
||||
int saveProjectionHeldBy(
|
||||
@Param("id") UUID id,
|
||||
@Param("owner") String owner,
|
||||
@Param("fence") long fence,
|
||||
@Param("deliveryState") String deliveryState,
|
||||
@Param("submissionOutcome") String submissionOutcome,
|
||||
@Param("deliveryOutcome") String deliveryOutcome,
|
||||
@Param("evidenceLevel") String evidenceLevel,
|
||||
@Param("ambiguousAttemptExists") boolean ambiguousAttemptExists,
|
||||
@Param("duplicateRisk") boolean duplicateRisk,
|
||||
@Param("routeCursor") int routeCursor,
|
||||
@Param("attemptCount") int attemptCount,
|
||||
@Param("lastFailureCategory") String lastFailureCategory,
|
||||
@Param("nextDispatchAt") Instant nextDispatchAt,
|
||||
@Param("now") Instant now);
|
||||
|
||||
/** Moves a job to a state, and only for the holder that still owns it. */
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
value =
|
||||
"UPDATE notification_recipient_delivery "
|
||||
+ "SET delivery_state = :deliveryState, next_dispatch_at = :nextDispatchAt, "
|
||||
+ "version = version + 1, updated_at = :now "
|
||||
+ "WHERE id = :id AND lease_owner = :owner AND lease_fence = :fence",
|
||||
nativeQuery = true)
|
||||
int transitionHeldBy(
|
||||
@Param("id") UUID id,
|
||||
@Param("owner") String owner,
|
||||
@Param("fence") long fence,
|
||||
@Param("deliveryState") String deliveryState,
|
||||
@Param("nextDispatchAt") Instant nextDispatchAt,
|
||||
@Param("now") Instant now);
|
||||
|
||||
/**
|
||||
* Extends a lease, and only for the holder that still owns it.
|
||||
*
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user