refactor: 각 어댑터터별 리펙토링 진행

This commit is contained in:
DongHyeonka
2026-08-24 18:26:40 +09:00
parent e98b56eb03
commit 0137263441
439 changed files with 31935 additions and 4719 deletions
+2
View File
@@ -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=
+5
View File
@@ -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
View File
@@ -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
+9 -4
View File
@@ -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 실행에서 다시 읽어 갱신한다 —
컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다.
+16 -92
View File
@@ -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.',
]
}
@@ -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();
}
}
@@ -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) {
@@ -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 {}
@@ -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()))
@@ -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);
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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);
@@ -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;
}
}
}
@@ -83,6 +83,7 @@ public enum GraphQlAdvancedModule {
"advanced.subscription",
"advanced.security",
"api",
"execution",
"http",
"security"),
@@ -118,6 +118,7 @@ public enum GraphQlStableModule {
"error",
"execution",
"http",
"observation",
"policy",
"security"),
@@ -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();
@@ -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),
@@ -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.
*
@@ -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));
@@ -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
@@ -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");
@@ -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();
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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();
}
}
@@ -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.
@@ -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));
}
}
@@ -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);
});
}
}
@@ -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 =
@@ -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);
}
}
}
@@ -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();
}
@@ -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"));
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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))));
}
}
@@ -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<>();
-1
View File
@@ -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 {
+22 -31
View File
@@ -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}")
}
}
}
}
}
@@ -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);
}
}
@@ -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();
}
}
}
@@ -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 {
+57 -89
View File
@@ -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')
+17 -2
View File
@@ -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'
}
@@ -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));
}
}
@@ -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);
}
@@ -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.
}
}
}
}
@@ -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);
@@ -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");
}
@@ -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);
}
}
@@ -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
@@ -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);
}
}
@@ -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);
@@ -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");
@@ -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());
}
@@ -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);
@@ -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.
*
@@ -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");
@@ -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
@@ -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;
@@ -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")));
@@ -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);
}
@@ -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("&amp;");
case '<' -> escaped.append("&lt;");
case '>' -> escaped.append("&gt;");
case '"' -> escaped.append("&quot;");
case '\'' -> escaped.append("&#x27;");
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");
}
@@ -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("&amp;");
case '<' -> escaped.append("&lt;");
case '>' -> escaped.append("&gt;");
case '"' -> escaped.append("&quot;");
case '\'' -> escaped.append("&#x27;");
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));
}
}
@@ -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
// `&amp;` 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;
}
}
@@ -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());
}
}
@@ -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();
}
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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());
}
}
@@ -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 =
@@ -29,6 +29,7 @@ class TwilioCallbackContractTest extends CallbackContract {
Optional.of("MG123"),
Optional.empty(),
CALLBACK_URL,
"cb-1",
Duration.ofSeconds(3),
Duration.ofHours(12));
@@ -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
@@ -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());
@@ -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());
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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() {
@@ -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}}";
}
}
@@ -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')
@@ -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 {
@@ -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");
@@ -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());
@@ -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();
@@ -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()
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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
@@ -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));
}
}
}
@@ -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(),
@@ -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);
}
}
@@ -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