chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# feature-container-runtime-contract — .dockerignore for src/ build context
|
||||
#
|
||||
# The docker build context is src/ (the Gradle root). This file excludes build
|
||||
# noise that must not enter the image build context, while keeping everything
|
||||
# the builder stage needs to resolve dependencies and run bootJar.
|
||||
|
||||
# ---- Version control --------------------------------------------------------
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# ---- Gradle build output ----------------------------------------------------
|
||||
# Exclude all module build/ directories; the builder stage produces them inside the container.
|
||||
build/
|
||||
**/build/
|
||||
.gradle/
|
||||
**/.gradle/
|
||||
|
||||
# ---- IDE / editor files -----------------------------------------------------
|
||||
.idea/
|
||||
**/.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
**/.vscode/
|
||||
*.eclipse
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# ---- Documentation / governance (not needed for image build) ----------------
|
||||
# Root CLAUDE.md and AGENTS.md are governance docs; not needed at build time.
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# ---- Environment / secrets (NEVER bake secrets into image layers) -----------
|
||||
.env
|
||||
**/.env
|
||||
*.env
|
||||
|
||||
# ---- OS artifacts -----------------------------------------------------------
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ---- Test reports and coverage ----------------------------------------------
|
||||
**/test-results/
|
||||
**/reports/
|
||||
**/jacoco/
|
||||
|
||||
# ---- Explicitly keep (negation rules to be safe) ----------------------------
|
||||
# The Gradle wrapper, source trees, and build configuration are needed.
|
||||
# Negation rules are not strictly required because the above globs don't
|
||||
# accidentally exclude src/*, but listed for clarity.
|
||||
!gradlew
|
||||
!gradlew.bat
|
||||
!gradle/
|
||||
!**/src/
|
||||
!**/build.gradle
|
||||
!settings.gradle
|
||||
@@ -0,0 +1,233 @@
|
||||
# ----------------------------------------------------------------------------
|
||||
# 외부화 설정의 단일 출처. spring-dotenv 가 src/.env 에서 로드합니다
|
||||
# (bootRun.workingDir = src/). 각 키의 허용값·결정 근거는 src/README.md 참조.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----- App identity -----
|
||||
APP_NAME=ca-skeleton
|
||||
SPRING_PROFILES_ACTIVE=local
|
||||
|
||||
# ----- Runtime safety (StartupSafetyValidator, D8) -----
|
||||
APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
|
||||
APP_LOG_BODY_CAPTURE_ENABLED=false
|
||||
APP_MULTI_INSTANCE_ENABLED=false
|
||||
APP_MIGRATION_ON_STARTUP=true
|
||||
APP_RATE_LIMIT_ENABLED=false
|
||||
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
|
||||
APP_RATE_LIMIT_PROVIDER=disabled
|
||||
APP_IDEMPOTENCY_TTL=24h
|
||||
APP_IDEMPOTENCY_PROVIDER=jdbc
|
||||
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
|
||||
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
|
||||
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
|
||||
APP_LEASE_PROVIDER=disabled
|
||||
APP_LEASE_REDIS_KEY_HMAC_SECRET=
|
||||
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
|
||||
|
||||
# ----- Async executor -----
|
||||
APP_ASYNC_EXECUTOR_CORE_SIZE=10
|
||||
APP_ASYNC_EXECUTOR_MAX_SIZE=50
|
||||
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
|
||||
|
||||
# ----- Optional integration adapters (default: all disabled) -----
|
||||
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
|
||||
# The single global Redis switch. False means no Redis settings, secrets, client, threads or
|
||||
# health contributor exist. Role selectors (cache/session/idempotency/lease/rate-limit) choose
|
||||
# which capabilities compose once Redis is on; none of them turns Redis on.
|
||||
APP_REDIS_ENABLED=false
|
||||
APP_MESSAGING_BROKER=
|
||||
APP_MESSAGING_KAFKA_BROKERS=
|
||||
APP_NOTIFICATION_SLACK_PROVIDER=
|
||||
APP_NOTIFICATION_EMAIL_PROVIDER=
|
||||
|
||||
# ----- Logging: root & app levels -----
|
||||
APP_LOG_LEVEL_ROOT=INFO
|
||||
APP_LOG_LEVEL_APP=DEBUG
|
||||
|
||||
# ----- Logging: per-package levels -----
|
||||
APP_LOG_LEVEL_SPRING=INFO
|
||||
APP_LOG_LEVEL_WEB=INFO
|
||||
APP_LOG_LEVEL_SQL=WARN
|
||||
|
||||
# ----- Logging: file output + rolling -----
|
||||
APP_LOG_FILE_ENABLED=false
|
||||
APP_LOG_FILE_PATH=logs/ca-skeleton.json
|
||||
APP_LOG_FILE_MAX_SIZE=100MB
|
||||
APP_LOG_FILE_MAX_HISTORY=14
|
||||
APP_LOG_FILE_TOTAL_SIZE_CAP=3GB
|
||||
|
||||
# ----- Logging: async appender -----
|
||||
APP_LOG_ASYNC_ENABLED=true
|
||||
APP_LOG_ASYNC_QUEUE_SIZE=512
|
||||
APP_LOG_ASYNC_DISCARDING_THRESHOLD=20
|
||||
|
||||
# ----- Logging: JSON encoder -----
|
||||
APP_LOG_JSON_TIMEZONE=UTC
|
||||
APP_LOG_JSON_TIMESTAMP_PATTERN=yyyy-MM-dd'T'HH:mm:ss.SSSXXX
|
||||
APP_LOG_JSON_INCLUDE_CALLER_DATA=false
|
||||
APP_LOG_JSON_LOGGER_NAME_LENGTH=0
|
||||
|
||||
# ----- Logging: sampling -----
|
||||
APP_LOG_SAMPLING_RATE=1.0
|
||||
|
||||
# ----- Distributed tracing -----
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
APP_TRACING_ENABLED=true
|
||||
APP_TRACING_SAMPLE_RATE=
|
||||
|
||||
# ----- Privacy: user_principal pseudonymization -----
|
||||
APP_PRIVACY_PSEUDONYMIZATION_SALT=__LOCAL_DEV_pseudonymization_salt
|
||||
|
||||
# ----- Spring Boot bootstrap -----
|
||||
SPRING_BANNER_MODE=console
|
||||
SPRING_MAIN_LAZY_INITIALIZATION=false
|
||||
SPRING_MAIN_LOG_STARTUP_INFO=true
|
||||
SPRING_THREADS_VIRTUAL_ENABLED=true
|
||||
|
||||
# ----- Jackson: deserialization policy -----
|
||||
SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES=true
|
||||
SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES=true
|
||||
SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES=true
|
||||
SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL=false
|
||||
|
||||
# ----- Jackson: serialization policy -----
|
||||
SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS=false
|
||||
|
||||
# ----- Server / Tomcat -----
|
||||
APP_SERVER_PORT=8080
|
||||
APP_SERVER_SHUTDOWN=graceful
|
||||
APP_SERVER_SHUTDOWN_TIMEOUT=30s
|
||||
APP_SERVER_TOMCAT_MAX_THREADS=200
|
||||
APP_SERVER_TOMCAT_MIN_SPARE_THREADS=10
|
||||
APP_SERVER_TOMCAT_ACCEPT_COUNT=100
|
||||
APP_SERVER_TOMCAT_MAX_CONNECTIONS=8192
|
||||
APP_SERVER_TOMCAT_CONNECTION_TIMEOUT=20s
|
||||
APP_SERVER_COMPRESSION_ENABLED=true
|
||||
APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE=1024
|
||||
APP_SERVER_FORWARD_HEADERS_STRATEGY=framework
|
||||
APP_SERVER_ERROR_INCLUDE_STACKTRACE=never
|
||||
APP_SERVER_ERROR_INCLUDE_MESSAGE=never
|
||||
|
||||
# ----- Presentation -----
|
||||
PRESENTATION_API_BASE_PATH=/api
|
||||
|
||||
# ----- Auth (OIDC resource server) -----
|
||||
APP_SECURITY_AUTH_MODE=jwt
|
||||
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
|
||||
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
|
||||
SECURITY_PUBLIC_PATHS=/api/healthcheck
|
||||
APP_SESSION_COOKIE_NAME=CA_SESSION
|
||||
APP_SESSION_COOKIE_SECURE=true
|
||||
APP_SESSION_COOKIE_HTTP_ONLY=true
|
||||
APP_SESSION_COOKIE_SAME_SITE=Lax
|
||||
APP_SESSION_COOKIE_PATH=/
|
||||
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
|
||||
APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN
|
||||
|
||||
# ----- CORS -----
|
||||
APP_SECURITY_CORS_ENABLED=true
|
||||
APP_SECURITY_CORS_ORIGINS=http://localhost:3000
|
||||
APP_SECURITY_CORS_ALLOWED_METHODS=
|
||||
APP_SECURITY_CORS_ALLOWED_HEADERS=*
|
||||
APP_SECURITY_CORS_ALLOW_CREDENTIALS=true
|
||||
APP_SECURITY_CORS_MAX_AGE=3600
|
||||
|
||||
# ----- Database (Postgres) -----
|
||||
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5433/ca_skeleton
|
||||
APP_DATASOURCE_USERNAME=ca_skeleton
|
||||
APP_DATASOURCE_PASSWORD=ca_skeleton
|
||||
APP_DATASOURCE_DRIVER=org.postgresql.Driver
|
||||
APP_DATASOURCE_DDL_AUTO=update
|
||||
APP_DATASOURCE_SHOW_SQL=false
|
||||
APP_DATASOURCE_FORMAT_SQL=false
|
||||
APP_DATASOURCE_OPEN_IN_VIEW=false
|
||||
|
||||
# ----- Database: HikariCP connection pool -----
|
||||
APP_DATASOURCE_POOL_MAX_SIZE=10
|
||||
APP_DATASOURCE_POOL_MIN_IDLE=2
|
||||
APP_DATASOURCE_CONNECTION_TIMEOUT=30000
|
||||
APP_DATASOURCE_POOL_IDLE_TIMEOUT=600000
|
||||
APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
|
||||
|
||||
# ----- Management / Actuator -----
|
||||
MANAGEMENT_SERVER_PORT=9001
|
||||
|
||||
# ----- Fileserver HTTP platform (app.fileserver-platform.*) -----
|
||||
# Off by default. While false nothing below is bound: the platform auto-configuration binds this
|
||||
# block itself and is not processed until the master switch is true.
|
||||
APP_FILESERVER_PLATFORM_ENABLED=false
|
||||
APP_FILESERVER_PLATFORM_INSTANCE_ID=local-node
|
||||
APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE=default
|
||||
|
||||
# Storage root must be an absolute path on its own volume, never under a web or config root.
|
||||
APP_FILESERVER_PLATFORM_STORAGE_ROOT=/var/lib/backend/files
|
||||
APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE=atomic-move-preferred
|
||||
APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE=128KB
|
||||
APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS=/app,/etc,/usr/share/nginx/html
|
||||
|
||||
# Shared with spring.servlet.multipart.* so the container and the policy cannot disagree.
|
||||
APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE=100MB
|
||||
APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE=110MB
|
||||
APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION=8MB
|
||||
APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS=16
|
||||
APP_FILESERVER_PLATFORM_UPLOAD_TTL=1h
|
||||
APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL=24h
|
||||
APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION=30s
|
||||
APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH=false
|
||||
|
||||
APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL=private, no-store
|
||||
APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED=false
|
||||
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES=1
|
||||
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES=100MB
|
||||
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED=true
|
||||
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES=16MB
|
||||
|
||||
APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE=8
|
||||
APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE=32
|
||||
APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY=64
|
||||
APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS=300
|
||||
|
||||
# required | role-based | unenforced (unenforced is refused under a production profile).
|
||||
APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY=required
|
||||
APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES=ROLE_FILE_READ
|
||||
APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES=ROLE_FILE_WRITE
|
||||
APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES=ROLE_FILE_ADMIN
|
||||
|
||||
APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT=5s
|
||||
APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT=false
|
||||
APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE=false
|
||||
|
||||
APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS=16
|
||||
APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS=4
|
||||
APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS=64
|
||||
APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER=0.70
|
||||
APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER=0.85
|
||||
|
||||
APP_FILESERVER_PLATFORM_ADMIN_ENABLED=false
|
||||
APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE=1h
|
||||
|
||||
APP_FILESERVER_PLATFORM_CLEANUP_ENABLED=false
|
||||
APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL=60s
|
||||
APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS=100
|
||||
APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES=1GB
|
||||
APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF=5m
|
||||
|
||||
APP_FILESERVER_PLATFORM_TUS_ENABLED=false
|
||||
APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED=false
|
||||
|
||||
APP_FILESERVER_PLATFORM_NGINX_ENABLED=false
|
||||
APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX=/__files/
|
||||
APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX=.bin
|
||||
APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE=16MB
|
||||
|
||||
APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED=true
|
||||
# Secret. Required while metrics are enabled; an unkeyed digest of an enumerable id is reversible.
|
||||
APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY=
|
||||
|
||||
# ----- HTTP Client platform (app.httpclient.*) -----
|
||||
# The single switch for outbound HTTP. False means no HTTP client property is bound, and no
|
||||
# transport provider, connection pool, TLS context, credential, thread or gateway is created.
|
||||
# The per-client surface is indexed and per-deployment, so it is set directly in the environment
|
||||
# rather than declared here; docs/httpclient/env-fields.yaml is its registry, and an
|
||||
# APP_HTTPCLIENT_ variable that is not in that registry fails startup.
|
||||
APP_HTTPCLIENT_ENABLED=false
|
||||
@@ -0,0 +1,17 @@
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# jqwik property-test runtime state
|
||||
.jqwik-database
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
|
||||
# =============================================================================
|
||||
# feature-container-runtime-contract — multi-stage image build
|
||||
#
|
||||
# Build requirements:
|
||||
# docker build -f src/Dockerfile src/ -t caskeleton:local \
|
||||
# --build-arg RELEASE_VERSION=1.2.3 \
|
||||
# --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \
|
||||
# --build-arg GIT_SHA=a1b2c3d4e5f6 \
|
||||
# --build-arg SOURCE_URL=https://github.com/your-org/your-repo
|
||||
#
|
||||
# Design decisions (feature-container-runtime-contract D2/D3/D4):
|
||||
# D2 — read-only root filesystem: writable mounts must be declared explicitly.
|
||||
# D3 — non-root user, JRE-only final stage (no full JDK).
|
||||
# D4 — JVM ergonomics via JAVA_TOOL_OPTIONS and ExitOnOutOfMemoryError.
|
||||
# =============================================================================
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
ARG BUILD_VERSION
|
||||
ARG GIT_SHA
|
||||
ARG SOURCE_URL
|
||||
|
||||
# ---- Stage 1: builder -------------------------------------------------------
|
||||
# Uses the full JDK only in the build stage, never in the final image.
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
ARG GIT_SHA
|
||||
|
||||
WORKDIR /build/src
|
||||
|
||||
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
||||
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
||||
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
|
||||
# single structure-preserving glob replaces the former per-module COPY list: new modules are
|
||||
# picked up automatically and this stage never drifts out of sync with settings.gradle again.
|
||||
# STRICT lock mode still rejects missing/drifted state at verifyDependencyLocks below.
|
||||
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
||||
COPY gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY config/ ./config/
|
||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||
|
||||
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
|
||||
# custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries
|
||||
# while still returning exit code 0 and therefore is not a release gate.
|
||||
RUN test -n "${RELEASE_VERSION}" \
|
||||
&& test -n "${GIT_SHA}" \
|
||||
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Copy full source and stage the executable JAR at Gradle's declared Docker output path.
|
||||
COPY . .
|
||||
RUN ./gradlew :app-bootstrap:stageDockerJar --no-daemon -x test \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# ---- Stage 2: runtime image -------------------------------------------------
|
||||
# JRE-only slim image (D3: no full JDK in production image).
|
||||
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
|
||||
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
|
||||
|
||||
ARG BUILD_VERSION
|
||||
ARG GIT_SHA
|
||||
ARG SOURCE_URL
|
||||
|
||||
# OCI image labels (build-arg placeholders — supply at docker build time).
|
||||
LABEL org.opencontainers.image.title="caskeleton" \
|
||||
org.opencontainers.image.source="${SOURCE_URL}" \
|
||||
org.opencontainers.image.revision="${GIT_SHA}" \
|
||||
org.opencontainers.image.version="${BUILD_VERSION}"
|
||||
|
||||
# A release image without source/version metadata is not an artifact this contract permits.
|
||||
RUN test -n "${BUILD_VERSION}" && test -n "${GIT_SHA}" && test -n "${SOURCE_URL}"
|
||||
|
||||
# ---- Locale / timezone (D4) -------------------------------------------------
|
||||
# C.UTF-8 is available in eclipse-temurin:21-jre-jammy without installing extra packages.
|
||||
# Do NOT use en_US.UTF-8 — it requires the locales package and may not exist in a slim image.
|
||||
ENV TZ=UTC \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
# ---- Writable HOME under read-only root fs (D2) -----------------------------
|
||||
# The app user is created with --no-create-home and the root filesystem is
|
||||
# read-only at runtime. Point $HOME at the writable /tmp tmpfs so libraries that
|
||||
# write under $HOME (e.g. java.util.prefs -> ~/.java/.userPrefs, some SDK caches)
|
||||
# do not fail with a read-only-filesystem error.
|
||||
ENV HOME=/tmp
|
||||
|
||||
# ---- JVM ergonomics (D4) ----------------------------------------------------
|
||||
# -XX:MaxRAMPercentage=75 — use up to 75% of the container memory limit for heap.
|
||||
# -XX:+UseContainerSupport — respect cgroup memory limits (default on JDK 10+, explicit here).
|
||||
# -XX:+ExitOnOutOfMemoryError — terminate immediately on OOM so the orchestrator can restart.
|
||||
# -XX:+HeapDumpOnOutOfMemoryError / -XX:HeapDumpPath — write a heap dump to the writable
|
||||
# /var/tmp/heap mount (see tmpfs mounts in compose files, D2).
|
||||
# -Dserver.tomcat.basedir=/tmp — redirect Tomcat temp files to /tmp (D2: read-only root fs).
|
||||
ENV JAVA_TOOL_OPTIONS="\
|
||||
-XX:MaxRAMPercentage=75 \
|
||||
-XX:+UseContainerSupport \
|
||||
-XX:+ExitOnOutOfMemoryError \
|
||||
-XX:+HeapDumpOnOutOfMemoryError \
|
||||
-XX:HeapDumpPath=/var/tmp/heap \
|
||||
-Dserver.tomcat.basedir=/tmp"
|
||||
|
||||
# ---- Filesystem layout (D2: read-only root filesystem) ----------------------
|
||||
# /var/tmp/heap — heap dump landing zone; must be a writable mount at runtime.
|
||||
# /tmp — Tomcat working directory (see JAVA_TOOL_OPTIONS above).
|
||||
# Both directories are declared here so tooling is aware of them; at runtime they
|
||||
# MUST be mounted as tmpfs (or host volumes) by the orchestrator (see compose files).
|
||||
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
|
||||
|
||||
# ---- Non-root user (D3) -----------------------------------------------------
|
||||
RUN groupadd --system --gid 1000 app \
|
||||
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
|
||||
|
||||
# ---- Fileserver storage root ------------------------------------------------
|
||||
# Created in the image with the runtime user's ownership and 0750, so a fresh named volume
|
||||
# mounted here inherits both. Without it the Fileserver platform's default root does not exist
|
||||
# on a read-only root filesystem, and the capability fails on its first upload rather than at
|
||||
# startup. This directory is a mount point, not a place to keep data in the image: an unmounted
|
||||
# container writes into the container layer and loses everything on replacement.
|
||||
RUN mkdir -p /var/lib/backend/files \
|
||||
&& chown app:app /var/lib/backend/files \
|
||||
&& chmod 0750 /var/lib/backend/files
|
||||
VOLUME ["/var/lib/backend/files"]
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/application.jar app.jar
|
||||
|
||||
USER app
|
||||
|
||||
# ---- Ports ------------------------------------------------------------------
|
||||
# 8080 — application HTTP port
|
||||
# 9001 — management / actuator port (parallel actuator branch wires this endpoint)
|
||||
EXPOSE 8080 9001
|
||||
|
||||
# ---- Health check -----------------------------------------------------------
|
||||
# Targets the actuator readiness probe on the management port (9001).
|
||||
# CROSS-FEATURE COUPLING: the /actuator/health/readiness endpoint is implemented
|
||||
# by the parallel runtime-health + actuator branches. The HEALTHCHECK is wired here
|
||||
# (container-side) and will pass once those branches are merged. In this worktree
|
||||
# the endpoint may return 404; the container will be UNHEALTHY until merged.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider \
|
||||
http://localhost:9001/actuator/health/readiness || exit 1
|
||||
|
||||
# ---- Entrypoint -------------------------------------------------------------
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
@@ -0,0 +1,129 @@
|
||||
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
|
||||
# =============================================================================
|
||||
# sample-portfolio standalone demo image — twin of src/Dockerfile.
|
||||
#
|
||||
# Builds and runs the REFERENCE app (SamplePortfolioApplication), not the
|
||||
# production composition root (CaSkeletonApplication) that src/Dockerfile builds.
|
||||
# The sample is the demo-friendly entrypoint: its application.yml self-provides
|
||||
# defaults for every env placeholder, so the only external dependency it needs
|
||||
# to boot is a reachable PostgreSQL (datasource + Flyway sample migrations).
|
||||
#
|
||||
# Build (no build-args required — this is a disposable demo, not a release artifact):
|
||||
# docker build -f src/Dockerfile.sample src/ -t ca-sample:local
|
||||
#
|
||||
# Run (point APP_DATASOURCE_URL at a reachable Postgres; localhost default shown):
|
||||
# docker run --rm -p 8080:8080 -p 9001:9001 \
|
||||
# -e APP_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/ca_skeleton \
|
||||
# ca-sample:local
|
||||
#
|
||||
# The multi-stage build, --parents descriptor glob, STRICT lock verification,
|
||||
# non-root user, read-only-root-fs writable mounts, and JVM container ergonomics
|
||||
# are all identical to src/Dockerfile — only the bootJar target differs. Keep the
|
||||
# two files' builder stages in sync.
|
||||
# =============================================================================
|
||||
|
||||
# Demo defaults so the image builds with zero build-args. Two format constraints from the root
|
||||
# build.gradle configuration guard (feature-build-release-supply-chain-contract D1/D9):
|
||||
# - RELEASE_VERSION must be bare MAJOR.MINOR.PATCH — no pre-release/build suffix (build.gradle L21).
|
||||
# The "-sample" marker therefore lives only on BUILD_VERSION, which is a label, not a gradle prop.
|
||||
# - GIT_SHA must be 7-40 hex chars (build.gradle L35); 0000000 is the placeholder.
|
||||
ARG RELEASE_VERSION=0.0.0
|
||||
ARG BUILD_VERSION=0.0.0-sample
|
||||
ARG GIT_SHA=0000000
|
||||
ARG SOURCE_URL=https://example.invalid/ca-tmpl-sample
|
||||
|
||||
# ---- Stage 1: builder -------------------------------------------------------
|
||||
# Uses the full JDK only in the build stage, never in the final image.
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
ARG GIT_SHA
|
||||
|
||||
WORKDIR /build/src
|
||||
|
||||
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
||||
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
||||
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
|
||||
# single structure-preserving glob replaces a per-module COPY list: new modules are picked up
|
||||
# automatically and this stage never drifts out of sync with settings.gradle.
|
||||
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
||||
COPY gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY config/ ./config/
|
||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||
|
||||
# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either).
|
||||
RUN test -n "${RELEASE_VERSION}" \
|
||||
&& test -n "${GIT_SHA}" \
|
||||
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Copy full source and stage the executable sample JAR at Gradle's declared Docker output path.
|
||||
COPY . .
|
||||
RUN ./gradlew :sample-portfolio:stageDockerJar --no-daemon -x test \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# ---- Stage 2: runtime image -------------------------------------------------
|
||||
# JRE-only slim image (no full JDK in the demo image either).
|
||||
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
|
||||
|
||||
ARG BUILD_VERSION
|
||||
ARG GIT_SHA
|
||||
ARG SOURCE_URL
|
||||
|
||||
# OCI image labels. Unlike the release image (src/Dockerfile), the demo image does NOT hard-fail
|
||||
# on missing metadata — the ARG defaults above keep it buildable with no build-args.
|
||||
LABEL org.opencontainers.image.title="caskeleton-sample" \
|
||||
org.opencontainers.image.description="ca-tmpl sample-portfolio reference/demo application" \
|
||||
org.opencontainers.image.source="${SOURCE_URL}" \
|
||||
org.opencontainers.image.revision="${GIT_SHA}" \
|
||||
org.opencontainers.image.version="${BUILD_VERSION}"
|
||||
|
||||
# ---- Locale / timezone ------------------------------------------------------
|
||||
ENV TZ=UTC \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
# ---- Writable HOME under read-only root fs ----------------------------------
|
||||
ENV HOME=/tmp
|
||||
|
||||
# ---- JVM ergonomics ---------------------------------------------------------
|
||||
# Identical to src/Dockerfile: container-aware heap, fail-fast on OOM, heap dump to a
|
||||
# writable mount, and Tomcat temp redirected to /tmp for a read-only root filesystem.
|
||||
ENV JAVA_TOOL_OPTIONS="\
|
||||
-XX:MaxRAMPercentage=75 \
|
||||
-XX:+UseContainerSupport \
|
||||
-XX:+ExitOnOutOfMemoryError \
|
||||
-XX:+HeapDumpOnOutOfMemoryError \
|
||||
-XX:HeapDumpPath=/var/tmp/heap \
|
||||
-Dserver.tomcat.basedir=/tmp"
|
||||
|
||||
# ---- Filesystem layout (read-only root filesystem) --------------------------
|
||||
# At runtime /var/tmp/heap and /tmp MUST be writable mounts (tmpfs/emptyDir).
|
||||
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
|
||||
|
||||
# ---- Non-root user ----------------------------------------------------------
|
||||
RUN groupadd --system --gid 1000 app \
|
||||
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/application.jar app.jar
|
||||
|
||||
USER app
|
||||
|
||||
# ---- Ports ------------------------------------------------------------------
|
||||
# 8080 — application HTTP port
|
||||
# 9001 — management / actuator port
|
||||
EXPOSE 8080 9001
|
||||
|
||||
# ---- Health check -----------------------------------------------------------
|
||||
# Actuator readiness probe on the management port (9001). Ignored by Kubernetes,
|
||||
# which uses its own probes — kept for docker/compose parity with src/Dockerfile.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider \
|
||||
http://localhost:9001/actuator/health/readiness || exit 1
|
||||
|
||||
# ---- Entrypoint -------------------------------------------------------------
|
||||
# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest.
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
# src — 빌드 스크립트 / 환경 변수 참조
|
||||
|
||||
`src/` 는 Gradle 멀티모듈 루트입니다. 모듈 경계·의존 방향 규칙은 루트 [CLAUDE.md](../CLAUDE.md)
|
||||
와 [AGENTS.md](../AGENTS.md), 모듈별 규칙은 각 모듈의 `CLAUDE.md` 가 SSOT 입니다.
|
||||
|
||||
이 문서는 [build.gradle](build.gradle) 과 [.env](.env) 의 코드 주석에서 덜어낸 **설정 항목 설명과
|
||||
결정 근거**를 모아둔 참조용 기록입니다. 두 파일에는 짧은 기능 주석과 "자세한 내용은 README 참조"
|
||||
포인터만 남기고, "왜 이렇게 했나"는 여기서 풀어 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## build.gradle — 빌드 / 검증 게이트
|
||||
|
||||
모든 모듈의 `check` 태스크는 아래 verify 게이트에 의존합니다. 빌드를 통과하려면 검사가
|
||||
모두 green 이어야 합니다.
|
||||
|
||||
| 게이트 | 하는 일 |
|
||||
| --- | --- |
|
||||
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
|
||||
| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
|
||||
| `verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env` 가 어긋나지 않는지 검사 |
|
||||
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
|
||||
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
|
||||
| `verifyReadmeCommands` | root README의 실행 가능한 Gradle/Compose/Make 명령이 실제 task/file/target과 일치하는지 검사 |
|
||||
|
||||
### Local bootstrap
|
||||
|
||||
`./gradlew bootstrap`은 `bootstrapCompile` → `bootstrapDependencies` →
|
||||
`bootstrapMigrateAndStart` → `bootstrapSampleContract` → `bootstrapSmoke`를 순서대로 실행합니다.
|
||||
DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가
|
||||
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의
|
||||
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
|
||||
|
||||
README command drift는 다음 명령으로 독립 실행할 수 있습니다.
|
||||
|
||||
```bash
|
||||
./gradlew verifyReadmeCommands
|
||||
```
|
||||
|
||||
### Traceable version + dependency locking
|
||||
|
||||
- 모든 project version은 `<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>`입니다. base version은
|
||||
`-PreleaseVersion`/`RELEASE_VERSION`, revision은 `-PgitRevision`/`GIT_SHA`/`GITHUB_SHA` 순으로
|
||||
주입하고, 로컬에서는 현재 Git commit을 읽습니다.
|
||||
- 모든 JAR manifest에 `Implementation-Version`과 `Build-Revision`을 기록합니다. Git metadata도
|
||||
revision property도 없는 상태는 traceable artifact를 만들 수 없으므로 configuration 단계에서
|
||||
실패합니다.
|
||||
- 모든 subproject가 `lockAllConfigurations()` + `LockMode.STRICT`를 사용하고, lock state는 Gradle
|
||||
기본 `<module>/gradle.lockfile`에 둡니다. 이 경로는 Renovate Gradle manager 기본 인식 경로와
|
||||
같습니다.
|
||||
- lock 갱신은 `./gradlew resolveAndLockAll --write-locks` 한 가지 명령으로 수행합니다. 이 task는
|
||||
`--write-locks`가 없으면 실패하며, 일반 build가 lock state를 조용히 다시 쓰지 못하게 합니다.
|
||||
|
||||
### Reproducible archives (D10)
|
||||
|
||||
모든 `AbstractArchiveTask`는 file timestamp 보존을 끄고, file order를 재현 가능하게 정렬하며,
|
||||
directory/file mode를 각각 `0755`/`0644`로 고정합니다. Java toolchain은 21이고 로컬·CI patch
|
||||
version은 root `.tool-versions`의 Temurin 값으로 맞춥니다.
|
||||
|
||||
두 번의 clean, no-cache `bootJar` SHA-256 비교는 다음 명령으로 실행합니다.
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
bash .github/scripts/verify-reproducible-build.sh
|
||||
```
|
||||
|
||||
이 검사는 동일 toolchain·동일 source revision 안에서 archive 재현성을 검증합니다. 서로 다른 JDK
|
||||
vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 않습니다.
|
||||
|
||||
### `-parameters` 컴파일러 플래그
|
||||
|
||||
- **결정.** 모든 subproject 의 Java 컴파일에 `-parameters` 플래그를 직접 설정합니다.
|
||||
- **근거.** Spring MVC 는 `@PathVariable` / `@RequestParam` 의 이름을 reflection 의 parameter
|
||||
metadata 에서 읽습니다. 이 플래그가 없으면 파라미터 이름이 `arg0`, `arg1` 로 컴파일되어 바인딩이
|
||||
깨집니다. Spring Boot Gradle 플러그인은 이 플래그를 자동으로 켜 주지만, 이 프로젝트는 플러그인을
|
||||
`apply false` 로 두기 때문에 자동 적용이 일어나지 않습니다. 그래서 각 subproject 의 `JavaCompile`
|
||||
에 직접 설정합니다.
|
||||
|
||||
### `verifyCleanArchitectureDependencies`
|
||||
|
||||
- **하는 일.** [config/architecture/modules.json](config/architecture/modules.json)의
|
||||
`allowed_dependencies`를 읽고, 실제 Gradle 프로젝트 의존(`api` / `implementation` /
|
||||
`compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를 실패시킵니다.
|
||||
- **JSON registry가 의존 방향의 SSOT 입니다.** 새 모듈이나 새 production 의존 edge를 추가하면
|
||||
registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는
|
||||
같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다.
|
||||
|
||||
### `verifyRuntimeModuleMembership`
|
||||
|
||||
- **하는 일.** 같은 registry의 `runtime_compositions`와 각 leaf의 `runtime_memberships`를 읽어
|
||||
`app-bootstrap`/`sample-portfolio`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly`
|
||||
project dependency와 정확히 대조합니다.
|
||||
- **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만 두
|
||||
shipped runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는
|
||||
실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다.
|
||||
- **변경 규칙.** production edge를 추가하거나 제거할 때 `allowed_dependencies`,
|
||||
`runtime_memberships`, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면 `check`가 실패합니다.
|
||||
|
||||
세 opt-in inbound transport의 test-only composition, 실제 wire 경계, positive-count/zero-skip 증거는
|
||||
다음 release-blocking aggregate로 실행합니다.
|
||||
|
||||
```bash
|
||||
./gradlew conditionalTransportQualification
|
||||
```
|
||||
|
||||
### `verifyOneTypePerFile` (code-conventions I6)
|
||||
|
||||
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
|
||||
파일 이름과 같은지 검사합니다 (Google Java Style Guide §3.4.1). `package-info.java`,
|
||||
`module-info.java` 는 예외입니다.
|
||||
- **근거.** 이 "파일 모양(file-shape)" 규칙은 ArchUnit 으로는 잡을 수 없습니다. ArchUnit 은 컴파일된
|
||||
bytecode 를 읽기 때문에 "한 파일에 몇 개의 타입이 있었는지", "파일 이름이 무엇이었는지" 같은 소스
|
||||
파일 레벨 정보를 볼 수 없습니다. 그래서 다른 `verify*` 게이트와 똑같이 기계적으로 강제하려고 소스
|
||||
파일을 직접 스캔하는 별도 태스크로 만들어 `check` 에 연결했습니다.
|
||||
|
||||
### `verifyEnvKeys`
|
||||
|
||||
- **하는 일.** `docs/registries/env-keys.yaml`, `application.yml`, `src/.env` 세 곳을 lock-step(서로
|
||||
어긋나지 않게) 으로 유지합니다. `env-keys.yaml` 이 `APP_` 키의 SSOT 이고, drift 가 생기면 빌드를
|
||||
실패시킵니다.
|
||||
- **막으려는 것 3가지.** (1) 필수 env 가 조용히 누락되는 것, (2) 더 이상 쓰지 않는 stale env 키가
|
||||
`.env` 에 남는 것, (3) 실제로 쓰는 `APP_` 키가 registry 에 등록되지 않고 빠져나가는 것.
|
||||
- **검사 항목.**
|
||||
- **A.** `application.yml` 의 placeholder 중 inline default(`${VAR:default}`)가 없는 **필수**
|
||||
placeholder(`${VAR}`)는 반드시 `.env` 에 존재해야 합니다.
|
||||
- **B.** `.env` 의 모든 키는 `application.yml` 의 어떤 `${...}` placeholder 가 참조해야 합니다.
|
||||
(아무도 안 쓰는 키는 orphan 으로 간주해 실패)
|
||||
- **C.** `.env` 의 모든 `APP_` 키는 registry 에 `- name: <KEY>` 행이 있어야 합니다.
|
||||
- **`SPRING_*` 키는 왜 registry 추적 대상이 아닌가.** `SPRING_*` 는 Spring Boot 가 정의한 native 키라
|
||||
프로젝트가 소유한 계약이 아닙니다. 그래서 C 검사는 일부러 `APP_` prefix 로만 범위를 좁혔습니다.
|
||||
- **비고.** `src/.env` 가 프로젝트의 커밋된 env 파일이며, 별도의 `.env.example` 템플릿은 두지
|
||||
않습니다.
|
||||
|
||||
### `verifyPublicPathSnapshot`
|
||||
|
||||
- **하는 일.** deny-by-default public path 표면이 승인 없이 바뀌면 빌드를 실패시킵니다.
|
||||
- **배경.** 이 앱은 deny-by-default 입니다. 즉 `SECURITY_PUBLIC_PATHS` 가 먹이는 명시적 `permitAll()`
|
||||
경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env` → `SecuritySettings.publicPaths()` →
|
||||
`SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는
|
||||
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
|
||||
- **승인 방법.** `verifyPublicPathSnapshot` 은 항상 읽기 전용입니다. reviewer 가 변경을 승인한 뒤
|
||||
`./gradlew updatePublicPathSnapshot -PapprovePublicPathChange` 로 snapshot 을 명시적으로 다시
|
||||
생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 재생성된 snapshot 을 함께 커밋합니다.
|
||||
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
|
||||
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
|
||||
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
|
||||
`RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()`
|
||||
을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로
|
||||
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
|
||||
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안
|
||||
baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh
|
||||
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한
|
||||
뒤 보안 리뷰와 함께 커밋합니다.
|
||||
|
||||
### `verifyTrivyignore`
|
||||
|
||||
- **하는 일.** repo 루트 `.trivyignore.yaml` 의 모든 Trivy suppression 항목이 (1) `id`, (2) 비어있지
|
||||
않은 `statement`(사유), (3) 미래이면서 90일 이내인 `expired_at`(만료일) 을 갖추었는지 검사하고,
|
||||
하나라도 빠지거나 이미 만료됐거나 90일을 초과하면 `./gradlew check` 를 실패시킵니다.
|
||||
- **막으려는 것.** 2026-05-25 ca-tmpl audit 에서 발견된 "만료일·사유 없는 suppression 을 추가해
|
||||
취약점을 영구히 조용히 우회"하는 구멍입니다. Trivy 는 `expired_at` 이 없으면 **영구 유효**로
|
||||
취급하므로(공식 문서), 만료일 누락 자체를 차단해야 합니다.
|
||||
- **두 겹의 보완 통제.** 이 게이트는 *필드 검증*(CI), `.github/CODEOWNERS` 는 *merge 승인*(GitHub
|
||||
네이티브)을 담당합니다. CODEOWNERS 는 "누가 파일을 바꿀 수 있는가"만, 이 게이트는 "필드가 갖춰졌는가"
|
||||
만 잡으므로 둘은 대체재가 아니라 보완재입니다.
|
||||
- **결정 — 90일 상한 (프로젝트 선택).** Trivy 문서는 `expired_at` 필드의 *존재*만 보장하고
|
||||
기간 상한은 권고하지 않습니다. 짧으면 재검토 부담이 늘고, 길면 사실상 영구 ignore 가 되는
|
||||
trade-off 에서 90일을 기본값으로 두었습니다. fork 는 `src/build.gradle` 의 `maxWindowDays` 로
|
||||
조정합니다.
|
||||
- **위치.** suppression 파일은 `docs/` 가 아니라 repo 루트(`.trivyignore.yaml`)에 둡니다 — Trivy 가
|
||||
스캔 루트에서 자동으로 읽는 커밋 대상 파일이기 때문입니다. 정책 전문(severity·KEV·license·SLA)은
|
||||
`.github/dependency-vulnerability-policy.md`, CI 배선은 `.github/workflows/dependency-vulnerability.yml`
|
||||
에 있습니다.
|
||||
|
||||
### `verifyQuarantineSunset` + 플래키 격리
|
||||
|
||||
- **하는 일.** 플래키(간헐 실패) 테스트는 JUnit 기본 `@Tag("quarantine")` 를 붙여 격리합니다. 메인
|
||||
`test` 태스크는 `excludeTags 'quarantine'` 로 이들을 **릴리스 게이트에서 제외**하므로 플래키 테스트가
|
||||
merge 를 막지 않습니다. 격리된 테스트는 별도 `./gradlew quarantineTest`(비차단, `ignoreFailures`)로만
|
||||
돕니다.
|
||||
- **막으려는 것.** 격리가 *영구 주차장* 이 되는 것. `verifyQuarantineSunset`(루트 태스크, `check` 에
|
||||
연결)이 매 빌드마다 (1) 레지스트리 스키마(`test`/`quarantined_since`/`reason`/`tracking_issue`),
|
||||
(2) **14일 sunset**(`quarantined_since` 가 14일을 넘으면 빌드 실패), (3) **drift**(소스에
|
||||
`@Tag("quarantine")` 가 달렸는데 레지스트리에 없으면 실패)를 검사합니다.
|
||||
- **결정 — 14일 sunset (프로젝트 선택).** Spotify/Google/MS 사례는 격리 버킷의 정당성만
|
||||
보이고(Fowler 는 반대), 14일이라는 정량값·자동 강제는 ca-tmpl 절충안입니다(`company-case-study`
|
||||
강도 — 공식 best practice 아님). fork 는 `src/build.gradle` 의 `sunsetDays` 로 조정합니다.
|
||||
- **위치.** 레지스트리는 `docs/`(gitignore) 가 아니라 repo 루트 `flaky-quarantine.yaml` 에 둡니다 —
|
||||
CI 가 읽어야 하는 커밋 대상 파일이기 때문입니다(`.trivyignore.yaml` 과 같은 이유). 스켈레톤은 빈
|
||||
버킷(`quarantined: []`)으로 출고됩니다.
|
||||
|
||||
### CI 게이트 배선
|
||||
|
||||
- **소유 범위.** 이 계약은 *게이트 배선*(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을
|
||||
소유합니다. 개별 scanner/tool/severity *정책* 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo
|
||||
SSOT 가 `.github/ci-gate-matrix.yml` 입니다. `.github/scripts/verify-gate-matrix.sh`(`gate-matrix-lint`
|
||||
잡)가 표 ↔ 실제 task/test/job 정합을 매 PR 마다 cross-check 합니다.
|
||||
- **워크플로.** `.github/workflows/ci-quality-gates.yml` 의 `release-gate` 잡이 모든 release-blocking
|
||||
게이트의 fan-in(단일 required status check)입니다. 플래키 `quarantine` 잡은 의도적으로 `needs` 에서
|
||||
제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는 `dependency-vulnerability.yml` 가 소유하며,
|
||||
GitHub Actions 는 워크플로 간 `needs` 를 못 쓰므로 branch protection 의 required check 합집합으로
|
||||
묶습니다.
|
||||
|
||||
---
|
||||
|
||||
## .env — 환경 변수 레퍼런스
|
||||
|
||||
`spring-dotenv` 가 `src/.env` 를 읽어 외부화 설정을 주입합니다(`bootRun` 의 working dir 가 `src/` 라
|
||||
이 파일이 잡힙니다). 아래는 섹션별 키 설명입니다. 따로 표기가 없으면 `restart-only`(값 변경 시 재기동
|
||||
필요)로 간주하세요.
|
||||
|
||||
### App identity
|
||||
|
||||
- **`APP_NAME`** — `spring.application.name` 과 JSON 로그의 `app` 필드. 자유 문자열.
|
||||
- **`SPRING_PROFILES_ACTIVE`** — 활성 Spring profile. 보통 `local` | `dev` | `stage` | `prod`. JSON
|
||||
로그의 `profile` 필드도 이 값을 씁니다.
|
||||
|
||||
### Runtime safety (기동 시 `StartupSafetyValidator` 가 fail-fast 검사, D8)
|
||||
|
||||
- **`APP_ERROR_DETAIL_EXPOSURE_ENABLED`** — 응답에 내부 에러 상세를 노출할지. `true` | `false`.
|
||||
**`prod` 프로필에서는 반드시 `false`** 여야 하며, 아니면 기동이 실패합니다.
|
||||
- **`APP_LOG_BODY_CAPTURE_ENABLED`** — 요청/응답 body 를 로그에 캡처할지. `true` | `false`.
|
||||
**`prod` 에서는 반드시 `false`**, 아니면 기동 실패.
|
||||
- **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede /
|
||||
leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다.
|
||||
- **`APP_RATE_LIMIT_ENABLED`** — provider-neutral edge rate-limit interceptor 활성화
|
||||
(429 + `Retry-After` + `X-RateLimit-*` 응답). 기본값은 `false`이며, `true`로 바꿀 때는
|
||||
`APP_RATE_LIMIT_PROVIDER=redis`와 canonical coordination role을 함께 구성해야 합니다.
|
||||
- **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` |
|
||||
`forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만**
|
||||
`forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다.
|
||||
- **`APP_IDEMPOTENCY_TTL`** — idempotency 레코드 기본 TTL. duration(예: `24h`, `72h`). 오래 도는 use
|
||||
case 는 최대 72h 까지 override 가능. (D6)
|
||||
|
||||
### Async executor
|
||||
|
||||
`@Async` `ThreadPoolTaskExecutor` 풀 크기 설정입니다.
|
||||
|
||||
- **`APP_ASYNC_EXECUTOR_CORE_SIZE`** — 항상 살아있는 워커 수. 1 이상 정수.
|
||||
- **`APP_ASYNC_EXECUTOR_MAX_SIZE`** — 워커 수 상한. core-size 이상.
|
||||
- **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**.
|
||||
1 이상 정수.
|
||||
|
||||
### Optional integration adapters
|
||||
|
||||
선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택
|
||||
모듈의 기본값). Layer 1 의 `@ConditionalOnProperty` 가 enabled 일 때만 실제 어댑터를 등록하고, 아니면
|
||||
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
|
||||
|
||||
- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`.
|
||||
- **`APP_CACHE_CANONICAL_DEFAULT_PROVIDER`** — canonical default semantic region 선택.
|
||||
`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).
|
||||
- **`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
|
||||
비활성.
|
||||
|
||||
### Outbound HTTP client
|
||||
|
||||
현재 canonical activation은 다음 두 설정 트리만 사용합니다.
|
||||
|
||||
```yaml
|
||||
ca-skeleton:
|
||||
capabilities:
|
||||
http-client:
|
||||
expected-state: DISABLED
|
||||
bindings: {}
|
||||
providers:
|
||||
http-client: {}
|
||||
```
|
||||
|
||||
- 기본 `DISABLED`는 binding/provider definition이 모두 비어 있어야 하며
|
||||
`DISABLED_VERIFIED`만 게시하고 client, executor, pool, retry/CB registry를 만들지 않습니다.
|
||||
- `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한
|
||||
buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에
|
||||
fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.
|
||||
- 기존 `APP_OUTBOUND_HTTP_*`와 `app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`,
|
||||
`app-bootstrap`의 application YAML, env-key registry에서 제거됐으며 canonical composition에
|
||||
입력하면 상태와 무관하게 기동을 거부합니다.
|
||||
- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아
|
||||
있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은
|
||||
`verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
|
||||
가리킨다고 읽히지 않도록 범위를 명시합니다.
|
||||
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
|
||||
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
|
||||
timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
|
||||
readiness를 증명하지 않습니다.
|
||||
|
||||
### Logging
|
||||
|
||||
**Root / app 레벨** — 허용값은 모두 `TRACE` | `DEBUG` | `INFO` | `WARN` | `ERROR` | `OFF`.
|
||||
|
||||
- **`APP_LOG_LEVEL_ROOT`** — root 로거 레벨.
|
||||
- **`APP_LOG_LEVEL_APP`** — 앱 패키지 레벨.
|
||||
|
||||
**패키지별 레벨**(root 를 덮어씀) — 동일 허용값.
|
||||
|
||||
- **`APP_LOG_LEVEL_SPRING`** / **`APP_LOG_LEVEL_WEB`** — 각 패키지 레벨.
|
||||
- **`APP_LOG_LEVEL_SQL`** — `DEBUG` 로 두면 JPA/jdbc 연결 후 SQL 문이 출력됩니다.
|
||||
|
||||
**파일 출력 + rolling**
|
||||
|
||||
- **`APP_LOG_FILE_ENABLED`** — `true` 면 rolling JSON 파일 appender 를 붙입니다.
|
||||
- **`APP_LOG_FILE_PATH`** — `bootRun` working dir(`src/`) 기준 상대 경로 또는 절대 경로.
|
||||
- **`APP_LOG_FILE_MAX_SIZE`** — 파일 1개 최대 크기(단위 `KB` | `MB` | `GB`).
|
||||
- **`APP_LOG_FILE_MAX_HISTORY`** — 보관할 rolled archive 개수. 1 이상 정수.
|
||||
- **`APP_LOG_FILE_TOTAL_SIZE_CAP`** — 전체 rolled 파일 용량 상한(단위 `KB` | `MB` | `GB`, `0` = 비활성).
|
||||
|
||||
**Async appender**
|
||||
|
||||
- **`APP_LOG_ASYNC_ENABLED`** — `true` 면 appender 를 `AsyncAppender` 로 감싸 non-blocking I/O.
|
||||
- **`APP_LOG_ASYNC_QUEUE_SIZE`** — back-pressure 전 in-memory 큐 깊이. 1 이상 정수.
|
||||
- **`APP_LOG_ASYNC_DISCARDING_THRESHOLD`** — 남은 큐 용량이 이 값 미만이면 `TRACE`/`DEBUG`/`INFO`
|
||||
이벤트를 버립니다(`WARN`/`ERROR` 는 항상 유지). `0` = 절대 버리지 않음. 0 이상 정수.
|
||||
|
||||
**JSON 인코더 세부**
|
||||
|
||||
- **`APP_LOG_JSON_TIMEZONE`** — IANA timezone(예: `UTC`, `Asia/Seoul`) 또는 `default`(JVM 기본).
|
||||
- **`APP_LOG_JSON_TIMESTAMP_PATTERN`** — 타임스탬프 패턴. 보통 ISO 8601
|
||||
(`yyyy-MM-dd'T'HH:mm:ss.SSSXXX`).
|
||||
- **`APP_LOG_JSON_INCLUDE_CALLER_DATA`** — `true` 면 file/method/line 을 추가. **성능 비용이 큽니다.**
|
||||
- **`APP_LOG_JSON_LOGGER_NAME_LENGTH`** — `0` = 로거 이름 전체, 양수 = 패키지 축약(예: `36` →
|
||||
`dev.caskeleton.bootstrap.Foo` 가 `d.c.bootstrap.Foo` 로).
|
||||
|
||||
**Sampling (`SamplingTurboFilter`)**
|
||||
|
||||
- **`APP_LOG_SAMPLING_RATE`** — `INFO` 이하 로그를 남길 확률. [0.0, 1.0] float. prod 는 `0.1`(10%
|
||||
샘플링)이 권장, `WARN`/`ERROR` 는 항상 유지. `1.0` = 샘플링 없음(dev/local/staging 기본).
|
||||
|
||||
### Distributed tracing
|
||||
|
||||
- **`OTEL_EXPORTER_OTLP_ENDPOINT`** — OTLP exporter endpoint. 빈 값 = exporter off(스켈레톤에서 OTel
|
||||
SEAM 미활성). 값이 있으면 유효한 URL 이어야 하며 기동 시 `TracingProperties` 가 검증합니다.
|
||||
- **`APP_TRACING_ENABLED`** — `false` 면 tracing seam 은 꺼지지만, `meta.traceId` 는
|
||||
`RequestLoggingFilter` 가 W3C `traceparent` 로 여전히 생성합니다(D4 disabled-fallback 보장).
|
||||
- **`APP_TRACING_SAMPLE_RATE`** — per-profile 기본값을 덮어쓰는 샘플 비율. [0.0, 1.0] float.
|
||||
- per-profile 기본값(D6): prod = `0.01`, staging = `0.10`, dev/local = `1.0`.
|
||||
- **결정 — 빈 값으로 두는 이유(D-1 ISSUE-1 fix).** 빈 값이어야 per-profile resolver 의 기본값이
|
||||
실제 tracer sampler 까지 도달합니다(`TracingSampleRateResolver` 가 SSOT). 값을 박으면 프로필별
|
||||
기본값이 무시되므로, 특정 비율을 강제하고 싶을 때만 채웁니다.
|
||||
|
||||
### Privacy: user_principal pseudonymization
|
||||
|
||||
- **`APP_PRIVACY_PSEUDONYMIZATION_SALT`** — secret 등급 HMAC-SHA-256 salt. `__LOCAL_DEV_` prefix 는 로컬 전용 sentinel 값이며,
|
||||
**prod 에서는 secret-manager 가 주입하는 실제 값**을 써야 합니다.
|
||||
|
||||
### Spring Boot bootstrap
|
||||
|
||||
- **`SPRING_BANNER_MODE`** — `off` | `console` | `log`.
|
||||
- **`SPRING_MAIN_LAZY_INITIALIZATION`** — `true` 면 빈 생성을 첫 사용 시점까지 지연.
|
||||
- **`SPRING_MAIN_LOG_STARTUP_INFO`** — `true` 면 `Starting`/`Started` 로그 출력.
|
||||
- **`SPRING_THREADS_VIRTUAL_ENABLED`** — `true` 면 Tomcat 요청 처리에 Java 21 virtual threads 사용.
|
||||
|
||||
### Jackson — deserialization policy
|
||||
|
||||
모든 request DTO 는 Jackson 경계를 지납니다. 아래 4개 스위치는 잘못된 입력을 **조용히 강제 변환하지
|
||||
않고 즉시 실패**하게 만듭니다. 개별 DTO 에 클래스 레벨 `@JsonIgnoreProperties(ignoreUnknown = true)`
|
||||
로 이 정책을 완화하는 것은 **금지**이며 ArchUnit 규칙으로 막혀 있습니다.
|
||||
|
||||
- **`..._FAIL_ON_UNKNOWN_PROPERTIES`** — `true`: 타입에 선언되지 않은 JSON 키를 거부(Jackson 2.13+
|
||||
기본).
|
||||
- **`..._FAIL_ON_NULL_FOR_PRIMITIVES`** — `true`: primitive 필드에 JSON `null` 이 와도 `0`/`false`
|
||||
로 강제 변환하지 않고 "필수 필드 누락" 에러로 노출. 또는 wrapper 타입(`Integer`/`Long`/`Boolean`)
|
||||
과 `Optional<T>` 를 쓰세요.
|
||||
- **`..._FAIL_ON_IGNORED_PROPERTIES`** — `true`: JSON 에 `@JsonIgnore` 처리된 필드가 들어오면 throw
|
||||
(조용히 버리는 대신 계약 drift 를 노출).
|
||||
- **`..._READ_UNKNOWN_ENUM_VALUES_AS_NULL`** — `false`(Jackson 기본 유지): 모르는 enum 값이 조용히
|
||||
`null` 이 되지 않고 throw 되어 `VALIDATION_FAILED` 로 드러나게 합니다.
|
||||
|
||||
### Jackson — serialization policy
|
||||
|
||||
응답 생성 쪽 정책입니다. 현재 Jackson/Spring Boot 기본값과 같지만 **명시적으로 못박아**, 미래에 Spring
|
||||
Boot 기본값이 바뀌어도 wire 계약이 조용히 깨지지 않게 합니다(`spring.mvc.problemdetails.enabled=false`
|
||||
와 같은 근거). `JacksonSerializationPolicyTest` 가 강제하며, `new BigDecimal(double)` 생성자는
|
||||
`no_bigdecimal_double_constructor` ArchUnit 규칙으로 금지됩니다.
|
||||
|
||||
- **`..._WRITE_DATES_AS_TIMESTAMPS`** — `false`(D2 / RFC 3339): `java.time` 값을 ISO-8601 문자열로
|
||||
직렬화(`OffsetDateTime` → `"...Z"`, `LocalDate` → `"YYYY-MM-DD"`). `true` 면 epoch 숫자나
|
||||
`[y,m,d,...]` 배열로 나가 datetime 계약이 깨집니다.
|
||||
- **BigDecimal plain output** — Jackson 3에는 별도 `WRITE_BIGDECIMAL_AS_PLAIN` 설정 키가 없습니다.
|
||||
`JacksonSerializationPolicyTest` 가 `"12300000000.00"` plain 출력을 직접 검증하고,
|
||||
`no_bigdecimal_double_constructor` ArchUnit 규칙이 부정확한 `new BigDecimal(double)` 생성을
|
||||
금지합니다. 엔드포인트별 string vs number 선택은 그대로 명시적으로 둡니다(공개/금융 API 는 string,
|
||||
내부 API 는 number+plain).
|
||||
|
||||
### Server / Tomcat
|
||||
|
||||
- **`APP_SERVER_PORT`** — 1~65535 정수.
|
||||
- **`APP_SERVER_SHUTDOWN`** — `graceful` | `immediate`.
|
||||
- **`APP_SERVER_SHUTDOWN_TIMEOUT`** — duration(예: `30s` | `1m` | `500ms`).
|
||||
- **`APP_SERVER_TOMCAT_MAX_THREADS`** — 동시 요청 워커 상한. 1 이상 정수.
|
||||
- **`APP_SERVER_TOMCAT_MIN_SPARE_THREADS`** — idle 워커 풀 하한. 0 이상 정수.
|
||||
- **`APP_SERVER_TOMCAT_ACCEPT_COUNT`** — 들어오는 TCP 연결의 OS backlog 큐 깊이. 0 이상 정수.
|
||||
- **`APP_SERVER_TOMCAT_MAX_CONNECTIONS`** — 동시에 열 수 있는 연결 수 상한. 1 이상 정수.
|
||||
- **`APP_SERVER_TOMCAT_CONNECTION_TIMEOUT`** — duration(예: `20s` | `1m`).
|
||||
- **`APP_SERVER_COMPRESSION_ENABLED`** — `true` | `false`.
|
||||
- **`APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE`** — 이 크기 미만 payload 는 압축하지 않음(bytes 또는
|
||||
단위, 예: `1024` | `1KB` | `2KB`).
|
||||
- **`APP_SERVER_FORWARD_HEADERS_STRATEGY`** — `none` | `native` | `framework`. LB/proxy 뒤에서
|
||||
`X-Forwarded-*` 를 신뢰할지.
|
||||
- **`APP_SERVER_ERROR_INCLUDE_STACKTRACE`** — `always` | `never` | `on_param`.
|
||||
- **`APP_SERVER_ERROR_INCLUDE_MESSAGE`** — `always` | `never` | `on_param`.
|
||||
|
||||
### Presentation
|
||||
|
||||
- **`PRESENTATION_API_BASE_PATH`** — 모든 controller 앞에 붙는 leading-slash 경로(예: `/api` | `/v1` |
|
||||
`""`).
|
||||
|
||||
### Auth (OIDC resource server)
|
||||
|
||||
- **`APP_SECURITY_JWT_ISSUER`** — OIDC issuer URI(Keycloak realm, Auth0 tenant 등). **필수** — 없으면
|
||||
기동 실패. 예: `https://keycloak.example.com/realms/ca-skeleton`.
|
||||
- **`APP_SECURITY_JWT_AUDIENCE`** — 기대하는 `aud` claim. 빈 값으로 두면 audience 검증을 건너뜁니다.
|
||||
- **`SECURITY_PUBLIC_PATHS`** — 인증을 우회하는 경로 CSV. `api-base-path` 뒤의 full path 를 씁니다(예:
|
||||
`/api/healthcheck`). 이 값의 변경은 `verifyPublicPathSnapshot` 게이트가 감시합니다(위 build.gradle
|
||||
설명 참조).
|
||||
|
||||
### CORS
|
||||
|
||||
- **`APP_SECURITY_CORS_ENABLED`** — `true` | `false`.
|
||||
- **`APP_SECURITY_CORS_ORIGINS`** — 허용 origin CSV(예:
|
||||
`http://localhost:3000,https://app.example.com`).
|
||||
- **`APP_SECURITY_CORS_ALLOWED_METHODS`** — 허용 메서드 CSV. 빈 값이면 기본값 사용(GET, POST, PATCH,
|
||||
PUT, DELETE, OPTIONS).
|
||||
- **`APP_SECURITY_CORS_ALLOWED_HEADERS`** — 허용 헤더 CSV. `*` = 모든 헤더 허용.
|
||||
- **`APP_SECURITY_CORS_ALLOW_CREDENTIALS`** — `true` | `false`.
|
||||
- **`APP_SECURITY_CORS_MAX_AGE`** — preflight 캐시 TTL(초).
|
||||
|
||||
### 프로파일과 데이터베이스
|
||||
|
||||
프로파일마다 데이터스토어가 다르고, 그 차이는 `app-bootstrap/src/main/resources/application-*.yml`
|
||||
가 소유합니다.
|
||||
|
||||
| 프로파일 | 데이터스토어 | 스키마 소유자 | 외부 인프라 |
|
||||
| --- | --- | --- | --- |
|
||||
| `local` (기본) | H2 in-memory | Hibernate `ddl-auto: create-drop` | 없음 |
|
||||
| `dev` | PostgreSQL | Flyway `db/migration/postgresql` | 필요 |
|
||||
| `prod` | PostgreSQL | Flyway `db/migration/postgresql` | 필요 |
|
||||
|
||||
`local` 은 `./gradlew :app-bootstrap:bootRun` 의 기본값(`src/.env` 의 `SPRING_PROFILES_ACTIVE=local`)
|
||||
이라 Docker 없이 바로 뜹니다. 아래 `APP_DATASOURCE_*` 값은 `local` 에서는 쓰이지 않고
|
||||
`application-local.yml` 이 덮어씁니다.
|
||||
|
||||
`local` 이 검증하는 것은 wiring·요청/응답·애플리케이션 로직이고, **검증하지 않는 것은 migration 과
|
||||
vendor 동작**입니다. H2 에는 migration tree 가 없어 migration 에만 존재하는 테이블(capability schema
|
||||
registry, polling-delivery·inbox stream, Spring Integration lock)이 만들어지지 않습니다. 해당
|
||||
capability 는 `local` 기본값에서 꺼져 있고, 켜면 테이블 없음으로 실패합니다.
|
||||
|
||||
`dev` 를 호스트에서 띄우려면(`SPRING_PROFILES_ACTIVE=dev`) PostgreSQL 이 필요합니다.
|
||||
`docker-compose.local.yml` 의 `db` 서비스가 루프백(`127.0.0.1:5433`)에만 게시하며, 이 주소가
|
||||
아래 `APP_DATASOURCE_URL` 의 커밋된 기본값입니다. 저장소 루트에서 실행합니다.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --wait db
|
||||
```
|
||||
|
||||
컨테이너로 띄우는 `./gradlew bootstrap` 경로는 이 호스트 포트를 쓰지 않습니다. compose 가 app
|
||||
컨테이너의 `APP_DATASOURCE_URL` 을 내부 네트워크 주소 `jdbc:postgresql://db:5432/...` 로 덮어씁니다.
|
||||
|
||||
`ca-skeleton.persistence.vendor`(`postgresql` | `h2`)가 어느 vendor 구성을 조립할지 고르는 단일
|
||||
스위치입니다. 값이 둘 중 하나가 아니면 기동이 실패하고, prod 에서 `h2` 이거나 datasource URL 이
|
||||
`jdbc:h2:` 이면 `PersistenceVendorProdSafetyValidator` 가 기동을 거부합니다(env 로 덮어써도 동일).
|
||||
|
||||
### Database (Postgres)
|
||||
|
||||
- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`). `dev`·`prod` 에서
|
||||
쓰이며, 커밋된 기본값 `jdbc:postgresql://localhost:5433/ca_skeleton` 은 위 compose `db` 서비스의
|
||||
호스트 주소입니다.
|
||||
- **`APP_DATASOURCE_USERNAME`** / **`APP_DATASOURCE_PASSWORD`** — DB 접속 계정.
|
||||
- **`APP_DATASOURCE_DRIVER`** — Hibernate dialect 에 맞는 드라이버(예: `org.postgresql.Driver`).
|
||||
- **`APP_DATASOURCE_DDL_AUTO`** — `none` | `validate` | `update` | `create` | `create-drop`. **prod
|
||||
는 `validate` 또는 `none`**(`JpaSchemaSafetyValidator` 가 기동 시 강제). `local` 은 이 값을 쓰지
|
||||
않습니다 — `application-local.yml` 이 `create-drop` 으로 고정합니다.
|
||||
- **`APP_DATASOURCE_SHOW_SQL`** — `true` 면 SQL 을 로그로 echo.
|
||||
- **`APP_DATASOURCE_FORMAT_SQL`** — SQL pretty-print(`SHOW_SQL=true` 일 때만 의미 있음).
|
||||
- **`APP_DATASOURCE_OPEN_IN_VIEW`** — Hibernate OSIV. **prod 에서는 피하세요.**
|
||||
|
||||
**HikariCP 커넥션 풀**
|
||||
|
||||
- **`APP_DATASOURCE_POOL_MAX_SIZE`** — DB 동시 연결 최대 수. 1 이상 정수.
|
||||
- **`APP_DATASOURCE_POOL_MIN_IDLE`** — warm 하게 유지하는 최소 idle 연결 수. 0 이상 정수.
|
||||
- **`APP_DATASOURCE_CONNECTION_TIMEOUT`** — `acquire()` 가 실패하기 전 대기 시간(ms).
|
||||
- **`APP_DATASOURCE_POOL_IDLE_TIMEOUT`** — idle 연결 회수 임계 시간(ms).
|
||||
- **`APP_DATASOURCE_POOL_MAX_LIFETIME`** — 연결의 최대 수명(ms). broker timeout 전에 rotate 하도록
|
||||
설정합니다.
|
||||
|
||||
### Management / Actuator
|
||||
|
||||
- **결정 — management 포트 분리.** actuator 엔드포인트를 앱 API 와 **같은 소켓에 노출하지 않으려고**
|
||||
별도 management 포트를 둡니다.
|
||||
- **`MANAGEMENT_SERVER_PORT`** — 1~65535 정수. **`APP_SERVER_PORT`(8080)와 달라야 합니다.**
|
||||
@@ -0,0 +1,80 @@
|
||||
# adapter:inbound:graphql — inbound GraphQL adapter (skeleton machinery)
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-graphql`
|
||||
- Gradle path: `:adapter:inbound:graphql`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:graphql:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.graphql`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
|
||||
규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- GraphQL 전송 인프라만: 최소 health 스키마(`skeleton.graphqls`) + `HealthGraphqlController`,
|
||||
프로토콜 에러 매핑(`GraphqlExceptionResolver`). Spring for GraphQL 이 스키마와 컨트롤러를
|
||||
자동 합성/바인딩하도록 얹는 얇은 계층이다.
|
||||
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
|
||||
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
|
||||
- classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를
|
||||
의존하지 않는다. 실제 채택 시 composition root 가 GraphQL leaf 와 인증/인가·CORS 정책,
|
||||
GraphiQL/introspection 운영 설정을 함께 명시해야 한다.
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
|
||||
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
|
||||
- test scope 에 한해 실제 HTTP 인증/CORS qualification 용 `spring-boot-starter-security`.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
|
||||
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
|
||||
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`, 일반 `..adapter.inbound..` 규칙이 이
|
||||
모듈을 자동 커버 — per-module 규칙 추가 불필요).
|
||||
- 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두는 것 — health 표면만 (web 의
|
||||
`HealthcheckController` 와 동일 원칙). feature 스키마/컨트롤러/매퍼는 sample 모듈이 소유한다.
|
||||
- 모듈별 `yml` — 설정은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에 산다.
|
||||
|
||||
## Error mapping (`Category → ErrorType`)
|
||||
|
||||
feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)를 던지면
|
||||
`GraphqlExceptionResolver` 가 `GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다.
|
||||
비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md).
|
||||
|
||||
## 향후 adopter 의 feature 기여 방법
|
||||
|
||||
- **스키마**: adopter feature 가 `src/main/resources/graphql/*.graphqls` 를 두면
|
||||
`classpath:graphql/**` 병합으로 합칠 수 있다.
|
||||
- **핸들러**: adopter 가 `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동
|
||||
바인딩된다.
|
||||
- **도메인 예외 매핑**: adopter 는 자신의 `DataFetcherExceptionResolver` 를 추가하거나
|
||||
`ApiErrorCarrier` 를 사용해 안정적 코드로 매핑할 수 있다.
|
||||
|
||||
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
|
||||
health 스키마만 소유한다.
|
||||
|
||||
## 명시적 미구현 범위(P2)
|
||||
|
||||
- feature GraphQL schema/resolver
|
||||
- query depth/cost 제한
|
||||
- persisted operation
|
||||
- DataLoader/batching
|
||||
- subscription
|
||||
|
||||
이 범위는 production GraphQL 표면 채택 시 별도 설계와 qualification 을 요구한다.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:graphql:test --console=plain
|
||||
./gradlew :adapter:inbound:graphql:test \
|
||||
--tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \
|
||||
--console=plain
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# adapter-graphql — 설계 결정 참조
|
||||
|
||||
인바운드 GraphQL 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.inbound.graphql`.
|
||||
|
||||
허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은
|
||||
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
|
||||
모아둔 참조용 기록이다.
|
||||
|
||||
---
|
||||
|
||||
## 왜 스켈레톤에 health 스키마 + 컨트롤러만 두는가
|
||||
|
||||
Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하므로 스켈레톤은
|
||||
`src/main/resources/graphql/skeleton.graphqls` 에 최소 스키마(`type Query { _health: String! }`)를
|
||||
싣고, `HealthGraphqlController` 가 그 필드를 상태 토큰(`UP`)으로 resolve 한다. web 어댑터의
|
||||
`HealthcheckController` 와 동일 원칙 — 스켈레톤은 **RPC/쿼리 0개**의 feature 로도 health 표면만으로
|
||||
부팅한다. 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두지 않는다.
|
||||
|
||||
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
|
||||
|
||||
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** 향후 composition root 가 이 모듈을 classpath 에
|
||||
명시적으로 채택하고 feature 를 추가하면 Spring for GraphQL 이 다음 두 축으로 합성할 수 있다:
|
||||
|
||||
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의
|
||||
향후 `worklog.graphqls` 같은 feature 스키마는 스켈레톤의 `skeleton.graphqls` 와 합쳐진다.
|
||||
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller` 의 `@QueryMapping`/`@MutationMapping`
|
||||
메서드를 바인딩한다. 향후 feature 의 GraphQL controller 는 스켈레톤을 수정하지 않고 등록할 수
|
||||
있다.
|
||||
|
||||
현재 `app-bootstrap` 과 `sample-portfolio` 의 production runtime 은 이 leaf 를 의존하지 않는다.
|
||||
즉 이 모듈은 **classpath opt-in** 이며, 현재 sample 에 feature GraphQL 스키마/controller 가 있다는
|
||||
뜻이 아니다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다.
|
||||
|
||||
## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제
|
||||
|
||||
`GraphqlExceptionResolver` 는 `DataFetcherExceptionResolverAdapter` 를 확장해, 데이터 페처가
|
||||
동기적으로 던진 예외 중 안정적 `ApiErrorCode` 를 실은 것(전송-중립 hook `ApiErrorCarrier` 구현)을
|
||||
`GraphQLError` 로 변환한다. 데이터 페처는 web 컨트롤러처럼 "그냥 던지기만" 하고, 이 resolver 가
|
||||
와이어 계약을 단일 소유한다.
|
||||
|
||||
- **`ErrorType` 분류**: `errorCode().category()` 를 GraphQL `ErrorType` 으로 매핑한다(아래 표).
|
||||
정확한 `code`/`category` 는 error `extensions{code, category}` 로 실어 클라이언트가 switch 하게
|
||||
한다(gRPC 가 status trailer 에 싣는 것과 동형).
|
||||
- **`ApiErrorCode` 추출**: shared-contract 의 `PersistenceFailureException` /
|
||||
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)과 feature 예외(자신의 도메인
|
||||
`ApiErrorCode` 를 실은 것)를 단일 `instanceof ApiErrorCarrier` 분기로 인식한다.
|
||||
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 error message/extensions 로 노출하고, raw
|
||||
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다.
|
||||
- **비-`ApiErrorCarrier`** 예외는 `null` 을 반환해 다른
|
||||
`DataFetcherExceptionResolver` 빈(예: sample 의 도메인 예외 resolver)과 Spring 기본 처리로
|
||||
넘긴다.
|
||||
|
||||
`Category → ErrorType` 표(설계 스펙 Error Mapping SSOT):
|
||||
|
||||
| `Category` | GraphQL `ErrorType` |
|
||||
|---|---|
|
||||
| VALIDATION | BAD_REQUEST |
|
||||
| AUTH | UNAUTHORIZED |
|
||||
| AUTHZ | FORBIDDEN |
|
||||
| NOT_FOUND | NOT_FOUND |
|
||||
| CONFLICT | BAD_REQUEST |
|
||||
| RATE_LIMIT | BAD_REQUEST |
|
||||
| TRANSIENT_DEPENDENCY | INTERNAL_ERROR |
|
||||
| PERMANENT_DEPENDENCY | INTERNAL_ERROR |
|
||||
| DATA_INTEGRITY | INTERNAL_ERROR |
|
||||
| INTERNAL | INTERNAL_ERROR |
|
||||
|
||||
## 의존성 버전 — strict locking
|
||||
|
||||
gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한다. 그래서 이 모듈은
|
||||
버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만
|
||||
선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다.
|
||||
|
||||
## 설정 — 프레임워크 `spring.graphql.*`
|
||||
|
||||
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
|
||||
location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다
|
||||
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
|
||||
|
||||
`GraphqlHttpBoundaryQualificationTest` 는 실제 random-port MVC HTTP 서버 위에서 test-only
|
||||
SecurityFilterChain 과 CORS allowlist 를 조합해 인증, origin, GraphiQL 비활성화, introspection
|
||||
비활성화, 오류 redaction 을 검증한다. 이 테스트 구성은 production 정책 bean 이 아니다. 실제
|
||||
composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을 함께 제공하고
|
||||
`spring.graphql.graphiql.enabled=false`,
|
||||
`spring.graphql.schema.introspection.enabled=false` 를 운영 설정으로 명시해야 한다.
|
||||
|
||||
## 아직 구현하지 않은 P2 범위
|
||||
|
||||
이 leaf 와 현재 sample 에는 feature GraphQL schema/resolver, query depth/cost 제한, persisted
|
||||
operation, DataLoader/batching, subscription 이 구현되어 있지 않다. 이 항목들은 실제 GraphQL 제품
|
||||
표면을 채택할 때 별도 설계·테스트와 함께 추가해야 한다.
|
||||
@@ -0,0 +1,38 @@
|
||||
// Driving adapter: GraphQL API (skeleton machinery, transport-only).
|
||||
//
|
||||
// Spring for GraphQL is schema-first: schema files live in src/main/resources/graphql/*.graphqls
|
||||
// and are merged from classpath:graphql/** at boot. This skeleton ships ONLY the minimal health
|
||||
// schema + @Controller so the module boots standalone with zero features (an empty schema fails to
|
||||
// start); a future consuming feature can contribute schema/controllers that compose automatically.
|
||||
//
|
||||
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
|
||||
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
|
||||
// coordinates the BOM does not manage).
|
||||
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
|
||||
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-graphql'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
|
||||
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
|
||||
// controller through a real AnnotatedControllerConfigurer and drives it with an
|
||||
// ExecutionGraphQlServiceTester.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-graphql-test'
|
||||
|
||||
// The HTTP boundary qualification test boots a real random-port servlet server and supplies
|
||||
// a test-only authentication/CORS composition. Security remains a composition-root concern;
|
||||
// this dependency does not add production security policy to the opt-in GraphQL adapter.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
}
|
||||
|
||||
registerStrictQualificationTest(
|
||||
name: 'graphqlTransportQualificationTest',
|
||||
sourceSet: sourceSets.test,
|
||||
requiredClasses: [
|
||||
'dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest'
|
||||
],
|
||||
description: 'Runs exact no-skip GraphQL conditional transport wire evidence.')
|
||||
@@ -0,0 +1,172 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import graphql.GraphQLError;
|
||||
import graphql.GraphqlErrorBuilder;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.Map;
|
||||
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Centralises the GraphQL error contract: a data fetcher just throws, and this resolver translates
|
||||
* any throwable carrying a stable {@link ApiErrorCode} (via the shared-contract {@link
|
||||
* ApiErrorCarrier} hook) into a {@link GraphQLError} with an {@link ErrorType} classification plus
|
||||
* machine-readable {@code code} / {@code category} extensions — the GraphQL sibling of the web
|
||||
* adapter's {@code GlobalExceptionHandler} and the gRPC adapter's {@code
|
||||
* GrpcExceptionHandlingInterceptor}.
|
||||
*
|
||||
* <p>The {@link ApiErrorCarrier} hook is implemented by the shared-contract {@code
|
||||
* PersistenceFailureException} / {@code DependencyFailureException} (an error surfacing from an
|
||||
* outbound adapter) and by feature throwables (which carry a mapped domain {@code ApiErrorCode}),
|
||||
* so a single {@code instanceof ApiErrorCarrier} branch covers them all. A non-carrier throwable
|
||||
* returns {@code null}: Spring for GraphQL then merges the other {@link
|
||||
* org.springframework.graphql.execution.DataFetcherExceptionResolver} beans (e.g. a feature's own
|
||||
* resolver mapping its domain exceptions) and finally its default handling. Only the stable {@link
|
||||
* ApiErrorCode#code()} reaches the client — never the raw exception message, which may carry a
|
||||
* SQLState or upstream detail.
|
||||
*/
|
||||
@Component
|
||||
public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter {
|
||||
|
||||
@Override
|
||||
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
|
||||
if (!(ex instanceof ApiErrorCarrier carrier)) {
|
||||
return null; // fall through to other resolvers / Spring's default handling
|
||||
}
|
||||
ApiErrorCode code = carrier.errorCode();
|
||||
var builder =
|
||||
GraphqlErrorBuilder.newError()
|
||||
.errorType(classify(code.category()))
|
||||
.message(code.code())
|
||||
.extensions(Map.of("code", code.code(), "category", code.category().name()));
|
||||
// A real GraphQL execution always supplies the environment; a unit test may pass null. Only
|
||||
// attach the field path/location when they are present.
|
||||
if (env != null) {
|
||||
builder.path(env.getExecutionStepInfo().getPath());
|
||||
if (env.getField() != null) {
|
||||
builder.location(env.getField().getSourceLocation());
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the 10-value operational {@link Category} SSOT to a GraphQL {@link ErrorType} (design
|
||||
* Error-Mapping table). The switch is exhaustive, so a new {@link Category} fails to compile
|
||||
* until a mapping decision is made.
|
||||
*/
|
||||
private static ErrorType classify(Category category) {
|
||||
return switch (category) {
|
||||
case VALIDATION, CONFLICT, RATE_LIMIT -> ErrorType.BAD_REQUEST;
|
||||
case AUTH -> ErrorType.UNAUTHORIZED;
|
||||
case AUTHZ -> ErrorType.FORBIDDEN;
|
||||
case NOT_FOUND -> ErrorType.NOT_FOUND;
|
||||
case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL ->
|
||||
ErrorType.INTERNAL_ERROR;
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import org.springframework.graphql.data.method.annotation.QueryMapping;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
/**
|
||||
* Minimal GraphQL health surface so the skeleton module boots standalone with zero features — the
|
||||
* GraphQL sibling of the web adapter's {@code HealthcheckController}. The {@code _health} query
|
||||
* resolves the {@code skeleton.graphqls} field of the same name to a fixed liveness token. Feature
|
||||
* queries/mutations may be contributed by a future consuming feature's {@code @Controller} beans
|
||||
* and merged by Spring for GraphQL; this controller never names a feature type.
|
||||
*/
|
||||
@Controller
|
||||
public class HealthGraphqlController {
|
||||
|
||||
/** Stable liveness token, matching the web adapter's {@code status=UP} health semantics. */
|
||||
static final String STATUS_UP = "UP";
|
||||
|
||||
// The schema field is `_health` (a conventional meta-field name); the Java method is `health` so
|
||||
// it satisfies the method-name checkstyle rule, with the field bound explicitly via `name`.
|
||||
@QueryMapping(name = "_health")
|
||||
public String health() {
|
||||
return STATUS_UP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Minimal GraphQL schema for the skeleton machinery module (schema-first).
|
||||
#
|
||||
# Spring for GraphQL merges every classpath:graphql/**/*.graphqls file at boot, so this health
|
||||
# schema can compose with schemas contributed by a future consuming feature. It exists so the module
|
||||
# boots standalone with zero features: Spring for GraphQL refuses to start on an empty schema, and
|
||||
# the skeleton must never name a feature type (mirrors web's HealthcheckController).
|
||||
type Query {
|
||||
"Liveness token for the GraphQL transport — mirrors the web adapter's /healthcheck."
|
||||
_health: String!
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import graphql.GraphQLError;
|
||||
import java.util.EnumSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
|
||||
/**
|
||||
* Pins the 10-value {@link Category} → {@link ErrorType} classification table (design
|
||||
* Error-Mapping) and the {@code code} / {@code category} extensions. One assertion per Category
|
||||
* value guards against a silent remap on a Spring/graphql-java upgrade. Driven directly against
|
||||
* {@code resolveToSingleError} with a null environment (no GraphQL engine needed), so it is a pure
|
||||
* mapping unit test — the boot-level wiring is covered by {@link HealthGraphqlControllerTest}.
|
||||
*/
|
||||
class GraphqlExceptionResolverTest {
|
||||
|
||||
private final GraphqlExceptionResolver resolver = new GraphqlExceptionResolver();
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"VALIDATION,BAD_REQUEST",
|
||||
"AUTH,UNAUTHORIZED",
|
||||
"AUTHZ,FORBIDDEN",
|
||||
"NOT_FOUND,NOT_FOUND",
|
||||
"CONFLICT,BAD_REQUEST",
|
||||
"RATE_LIMIT,BAD_REQUEST",
|
||||
"TRANSIENT_DEPENDENCY,INTERNAL_ERROR",
|
||||
"PERMANENT_DEPENDENCY,INTERNAL_ERROR",
|
||||
"DATA_INTEGRITY,INTERNAL_ERROR",
|
||||
"INTERNAL,INTERNAL_ERROR",
|
||||
})
|
||||
void mapsEachCategoryToItsErrorTypeWithExtensions(Category category, ErrorType expected) {
|
||||
GraphQLError error =
|
||||
resolver.resolveToSingleError(new CarrierException("SOME_CODE", category), null);
|
||||
|
||||
assertThat(error).isNotNull();
|
||||
assertThat(error.getErrorType()).isEqualTo(expected);
|
||||
assertThat(error.getExtensions())
|
||||
.containsEntry("code", "SOME_CODE")
|
||||
.containsEntry("category", category.name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void coversEveryCategoryValue() {
|
||||
// Fails the moment a new Category is added without a mapping decision (switch is exhaustive).
|
||||
for (Category category : EnumSet.allOf(Category.class)) {
|
||||
assertThat(resolver.resolveToSingleError(new CarrierException("C", category), null))
|
||||
.isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void surfacesOnlyTheStableCodeAsTheMessageNotTheRawException() {
|
||||
GraphQLError error =
|
||||
resolver.resolveToSingleError(
|
||||
new CarrierException("WORKLOG_NOT_FOUND", Category.NOT_FOUND), null);
|
||||
|
||||
assertThat(error.getMessage()).isEqualTo("WORKLOG_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullForNonCarrierExceptionSoOtherResolversHandleIt() {
|
||||
assertThat(resolver.resolveToSingleError(new IllegalStateException("boom"), null)).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-style throwable carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
|
||||
*/
|
||||
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private final ApiErrorCode errorCode;
|
||||
|
||||
CarrierException(String code, Category category) {
|
||||
super(code);
|
||||
this.errorCode = new TestErrorCode(code, category);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal {@link ApiErrorCode} — only {@code code} / {@code category} matter for the mapping. */
|
||||
private record TestErrorCode(String code, Category category) implements ApiErrorCode {
|
||||
@Override
|
||||
public int httpStatus() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retryable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
|
||||
import org.springframework.boot.resttestclient.TestRestTemplate;
|
||||
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.graphql.data.method.annotation.QueryMapping;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Release qualification for the opt-in GraphQL adapter's real servlet HTTP boundary.
|
||||
*
|
||||
* <p>The nested application deliberately owns only test authentication and CORS policy. A real
|
||||
* composition root must make those choices when it opts into this adapter; the adapter itself
|
||||
* remains free of an unconditional production security policy.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = GraphqlHttpBoundaryQualificationTest.TestApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {
|
||||
"spring.graphql.graphiql.enabled=false",
|
||||
"spring.graphql.schema.introspection.enabled=false",
|
||||
"spring.graphql.schema.locations=classpath:graphql-qualification-no-discovery/",
|
||||
"spring.graphql.schema.additional-files="
|
||||
+ "classpath:graphql/skeleton.graphqls,"
|
||||
+ "classpath:graphql-qualification/qualification.graphqls"
|
||||
})
|
||||
@AutoConfigureTestRestTemplate
|
||||
class GraphqlHttpBoundaryQualificationTest {
|
||||
|
||||
private static final String USERNAME = "qualification-user";
|
||||
private static final String PASSWORD = "qualification-password";
|
||||
private static final String ALLOWED_ORIGIN = "https://allowed.example";
|
||||
private static final String DISALLOWED_ORIGIN = "https://disallowed.example";
|
||||
private static final String STABLE_CODE = "QUALIFICATION_NOT_FOUND";
|
||||
private static final String CARRIER_SECRET = "carrier-secret-sqlstate-zz9";
|
||||
private static final String UNKNOWN_SECRET = "unknown-secret-upstream-token-yy8";
|
||||
|
||||
@LocalServerPort int port;
|
||||
|
||||
@Autowired TestRestTemplate http;
|
||||
|
||||
@Test
|
||||
void unauthenticatedGraphqlRequestIsRejected() {
|
||||
ResponseEntity<String> response = graphql("{ _health }", false, null);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedHealthQuerySucceedsOverHttp() {
|
||||
ResponseEntity<String> response = graphql("{ _health }", true, null);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("\"_health\":\"UP\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedOriginReceivesCorsPermission() {
|
||||
ResponseEntity<String> response = graphql("{ _health }", true, ALLOWED_ORIGIN);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getHeaders().getAccessControlAllowOrigin()).isEqualTo(ALLOWED_ORIGIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disallowedOriginIsRejected() {
|
||||
ResponseEntity<String> response = graphql("{ _health }", true, DISALLOWED_ORIGIN);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||
assertThat(response.getHeaders().getAccessControlAllowOrigin()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void graphiqlIsDisabledAtTheHttpBoundary() {
|
||||
ResponseEntity<String> response =
|
||||
http.withBasicAuth(USERNAME, PASSWORD).getForEntity(endpoint("/graphiql"), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaIntrospectionIsDisabledAtTheHttpBoundary() {
|
||||
ResponseEntity<String> response = graphql("{ __schema { queryType { name } } }", true, null);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("\"errors\"").doesNotContain("\"queryType\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void carrierErrorExposesStableCodeAndCategoryWithoutRawMessage() {
|
||||
ResponseEntity<String> response = graphql("{ carrierFailure }", true, null);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("\"message\":\"" + STABLE_CODE + "\"")
|
||||
.contains("\"code\":\"" + STABLE_CODE + "\"")
|
||||
.contains("\"category\":\"NOT_FOUND\"")
|
||||
.doesNotContain(CARRIER_SECRET, UNKNOWN_SECRET);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownErrorUsesFrameworkFallbackWithoutRawMessage() {
|
||||
ResponseEntity<String> response = graphql("{ unknownFailure }", true, null);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("\"classification\":\"INTERNAL_ERROR\"")
|
||||
.doesNotContain(CARRIER_SECRET, UNKNOWN_SECRET);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> graphql(String query, boolean authenticated, String origin) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
if (authenticated) {
|
||||
headers.setBasicAuth(USERNAME, PASSWORD);
|
||||
}
|
||||
if (origin != null) {
|
||||
headers.setOrigin(origin);
|
||||
}
|
||||
RequestEntity<String> request =
|
||||
new RequestEntity<>(
|
||||
"{\"query\":\"" + query + "\"}", headers, HttpMethod.POST, endpoint("/graphql"));
|
||||
return http.exchange(request, String.class);
|
||||
}
|
||||
|
||||
private URI endpoint(String path) {
|
||||
return URI.create("http://localhost:" + port + path);
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@Import({
|
||||
HealthGraphqlController.class,
|
||||
GraphqlExceptionResolver.class,
|
||||
QualificationController.class,
|
||||
TestSecurityConfiguration.class
|
||||
})
|
||||
static class TestApplication {}
|
||||
|
||||
@Controller
|
||||
static class QualificationController {
|
||||
|
||||
@QueryMapping
|
||||
String carrierFailure() {
|
||||
throw new QualificationCarrierException(CARRIER_SECRET);
|
||||
}
|
||||
|
||||
@QueryMapping
|
||||
String unknownFailure() {
|
||||
throw new IllegalStateException(UNKNOWN_SECRET);
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class TestSecurityConfiguration {
|
||||
|
||||
/**
|
||||
* Boot's schema condition does not inspect additional-files. This no-op customizer activates
|
||||
* auto-configuration while the exact shipped schema and test extension are supplied above.
|
||||
*/
|
||||
@Bean
|
||||
GraphQlSourceBuilderCustomizer qualificationSchemaActivation() {
|
||||
return builder -> {};
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain qualificationSecurityFilterChain(
|
||||
HttpSecurity http,
|
||||
@Qualifier("qualificationCorsConfigurationSource")
|
||||
CorsConfigurationSource corsConfigurationSource)
|
||||
throws Exception {
|
||||
return http.cors(cors -> cors.configurationSource(corsConfigurationSource))
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService qualificationUsers() {
|
||||
return new InMemoryUserDetailsManager(
|
||||
User.withUsername(USERNAME).password("{noop}" + PASSWORD).roles("QUALIFICATION").build());
|
||||
}
|
||||
|
||||
@Bean
|
||||
CorsConfigurationSource qualificationCorsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of(ALLOWED_ORIGIN));
|
||||
configuration.setAllowedMethods(List.of("POST"));
|
||||
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/graphql", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class QualificationCarrierException extends RuntimeException
|
||||
implements ApiErrorCarrier {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
QualificationCarrierException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return QualificationErrorCode.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
private enum QualificationErrorCode implements ApiErrorCode {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public String code() {
|
||||
return STABLE_CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Category category() {
|
||||
return Category.NOT_FOUND;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int httpStatus() {
|
||||
return 404;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retryable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.graphql.ExecutionGraphQlService;
|
||||
import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer;
|
||||
import org.springframework.graphql.execution.DefaultExecutionGraphQlService;
|
||||
import org.springframework.graphql.execution.GraphQlSource;
|
||||
import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester;
|
||||
import org.springframework.graphql.test.tester.GraphQlTester;
|
||||
|
||||
/**
|
||||
* Assembles the skeleton schema ({@code graphql/skeleton.graphqls}) and the {@link
|
||||
* HealthGraphqlController}'s {@code @QueryMapping} through a real {@link
|
||||
* AnnotatedControllerConfigurer} — the same wiring Spring for GraphQL uses at runtime — and drives
|
||||
* the {@code _health} query with a {@link GraphQlTester}. Self-contained (no Spring Boot context),
|
||||
* so it proves the module stands up a working GraphQL surface (schema + controller binding) with
|
||||
* zero features, the GraphQL sibling of the gRPC skeleton's health boot test.
|
||||
*/
|
||||
class HealthGraphqlControllerTest {
|
||||
|
||||
@Test
|
||||
void healthQueryReturnsUpLivenessToken() {
|
||||
graphQlTester()
|
||||
.document("{ _health }")
|
||||
.execute()
|
||||
.path("_health")
|
||||
.entity(String.class)
|
||||
.isEqualTo("UP");
|
||||
}
|
||||
|
||||
private static GraphQlTester graphQlTester() {
|
||||
AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext();
|
||||
appContext.registerBean(HealthGraphqlController.class);
|
||||
appContext.refresh();
|
||||
|
||||
AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer();
|
||||
controllerConfigurer.setApplicationContext(appContext);
|
||||
controllerConfigurer.afterPropertiesSet();
|
||||
|
||||
GraphQlSource source =
|
||||
GraphQlSource.schemaResourceBuilder()
|
||||
.schemaResources(new ClassPathResource("graphql/skeleton.graphqls"))
|
||||
.configureRuntimeWiring(controllerConfigurer)
|
||||
.build();
|
||||
|
||||
ExecutionGraphQlService service = new DefaultExecutionGraphQlService(source);
|
||||
return ExecutionGraphQlServiceTester.create(service);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
extend type Query {
|
||||
carrierFailure: String
|
||||
unknownFailure: String
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# adapter:inbound:grpc — inbound gRPC adapter (skeleton machinery)
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-grpc`
|
||||
- Gradle path: `:adapter:inbound:grpc`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:grpc:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.grpc`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
|
||||
규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- gRPC 전송 인프라만: 서버 수명주기(`GrpcServerRunner`), 타입드 설정(`GrpcServerProperties`),
|
||||
feature 인증 정책 경계, 프로토콜 에러 매핑(`GrpcStatusMapper` +
|
||||
`GrpcExceptionHandlingInterceptor`), 그리고 `.proto` 없이도 부팅하는 최소 표면(standard
|
||||
health, 명시적으로 켠 경우에만 reflection).
|
||||
- feature-agnostic: 모든 `io.grpc.BindableService` 빈을 generic 하게 등록하며 구체 기능을
|
||||
이름으로 알지 않는다.
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||
- `io.grpc:*` (grpc-netty-shaded / grpc-protobuf / grpc-stub / grpc-services),
|
||||
`spring-boot-starter`, `spring-boot-starter-validation`.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
|
||||
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
|
||||
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`).
|
||||
- 이 스켈레톤 모듈에서의 `com.google.protobuf` 플러그인 / `.proto` — 향후 도입하는 스키마와
|
||||
서비스는 consuming feature 모듈이 소유한다.
|
||||
- 프로덕션 feature RPC 를 스켈레톤에 두는 것 — health/reflection 표면만 (web 의
|
||||
`HealthcheckController` 와 동일 원칙).
|
||||
|
||||
## Config knobs (`ca-skeleton.grpc.*`)
|
||||
|
||||
타입드 `@ConfigurationProperties` 만 두고, 값은 composition-root `application.yml` 에 산다
|
||||
(모듈별 `yml` 없음).
|
||||
|
||||
| key | default | 의미 |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | `true`를 명시해야만 관련 빈과 listener가 생긴다 |
|
||||
| `port` | `9090` | 바인딩 TCP 포트. `0` 이면 ephemeral 포트(테스트) |
|
||||
| `bindAddress` | `127.0.0.1` | P1 insecure listener 바인드. loopback 주소만 허용한다 |
|
||||
| `allowInsecureLocal` | `false` | local plaintext 위험을 명시적으로 승인하는 개발용 override |
|
||||
| `reflectionEnabled` | `false` | v1 server reflection 노출을 독립적으로 opt-in 한다 |
|
||||
| `shutdownGraceSeconds` | `5` | graceful shutdown 시 in-flight RPC 대기 초(0 이상) |
|
||||
|
||||
`port`는 0..65535 범위여야 한다. 현재 transport credential은 plaintext뿐이므로
|
||||
`enabled=true`는 `allowInsecureLocal=true`와 실제 loopback `bindAddress`가 함께 없으면
|
||||
configuration binding/startup 단계에서 실패한다.
|
||||
|
||||
## Feature 기여 방법
|
||||
|
||||
현재 저장소에는 production feature RPC나 sample gRPC service가 없다. 향후 feature를 도입할
|
||||
때는 `.proto`/generated stub/`BindableService`를 해당 feature가 소유하고, 서비스 빈과 정확히 한
|
||||
개의 caller-supplied `GrpcAuthenticationPolicy` 빈을 함께 제공한다. 정책이 없거나 여러 개면
|
||||
listener 시작이 실패한다. 정책이 `false`를 반환하거나 예외를 던진 요청은 feature handler에 닿지
|
||||
않고 안정적인 `UNAUTHENTICATED` status/code/category로 종료된다.
|
||||
|
||||
## P1 증거와 한계
|
||||
|
||||
- `GrpcSafeActivationTest`: 기본 비활성, 관련 빈 부재, 설정 검증, feature 인증 정책 필수 조건.
|
||||
- `GrpcP1BoundaryWireTest`: 실제 loopback ephemeral Netty unary service의 auth 성공/실패,
|
||||
reflection-off, handler/listener/observer/raw-status 오류 sanitization과 sentinel redaction.
|
||||
|
||||
이 증거는 local insecure unary qualification일 뿐 production-ready 근거가 아니다. TLS/mTLS,
|
||||
external bind, deadline, streaming/backpressure, generated protobuf 호환성은 P2로 남아 있다.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:grpc:test
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
# adapter-grpc — 설계 결정 참조
|
||||
|
||||
인바운드 gRPC 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.inbound.grpc`.
|
||||
|
||||
허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은
|
||||
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
|
||||
모아둔 참조용 기록이다.
|
||||
|
||||
---
|
||||
|
||||
## 왜 self-managed Netty 인가
|
||||
|
||||
- **third-party grpc-spring-boot starter 를 쓰지 않는다.** `GrpcServerRunner` 가 io.grpc Netty
|
||||
`Server` 를 Spring `SmartLifecycle` 빈으로 직접 소유한다. starter 를 쓰면 Spring Boot 릴리스에
|
||||
버전이 커플링되는데, 스켈레톤은 io.grpc 런타임에만 의존해 그 커플링을 피한다(web 어댑터가
|
||||
third-party 없이 서블릿 컨테이너를 쓰는 것과 같은 정신).
|
||||
- **`getPhase()` = `Integer.MAX_VALUE - 1`.** web 서버가 뜬 **뒤에** 시작하고 종료 시 web 서버
|
||||
**전에** 멈춘다(SmartLifecycle: 높은 phase 가 늦게 시작·먼저 종료). gRPC 는 web 과 별개의 TCP
|
||||
포트를 소유하는 부가 전송이므로 애플리케이션 수명주기 맨 바깥에 둔다.
|
||||
- **graceful shutdown.** `shutdownGraceSeconds` 동안 in-flight RPC 를 기다린 뒤
|
||||
`shutdownNow()`. 종료 진입 시 health 를 `enterTerminalState()`(NOT_SERVING)로 뒤집어
|
||||
로드밸런서가 드레이닝을 인지하게 한다.
|
||||
- **fail-closed local insecure bind.** 기본은 서버 비활성·reflection 비활성이다. 현재 구현의
|
||||
plaintext credential은 `allowInsecureLocal=true`를 명시하고 실제 loopback 주소에 바인딩할
|
||||
때만 허용한다. wildcard/외부 주소의 insecure 시작은 실패한다.
|
||||
|
||||
## 왜 `.proto` 도 protobuf 플러그인도 없는가
|
||||
|
||||
이 모듈은 protobuf 를 **하나도 컴파일하지 않는다** — `com.google.protobuf` 플러그인도,
|
||||
`src/main/proto` 도 없다. health(`grpc.health.v1`) 와 v1 server reflection 은 `grpc-services`
|
||||
런타임 jar에 이미 컴파일된 채 들어 있다. 서버를 명시적으로 켜면 feature RPC가 없어도 health로
|
||||
수명주기를 확인할 수 있고, reflection은 별도 flag를 켠 경우에만 등록된다. 현재 저장소에는 feature
|
||||
`.proto`, generated stub, feature gRPC service가 없다. 향후 도입하는 feature 모듈이 이들을
|
||||
소유해야 한다.
|
||||
|
||||
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
|
||||
|
||||
향후 feature 모듈은 `BindableService`와 정확히 한 개의 `GrpcAuthenticationPolicy`를 Spring
|
||||
빈으로 함께 기여한다. runner는 feature 이름을 알지 않고 generic하게 등록하되, 서비스가 하나라도
|
||||
있는데 인증 정책이 없거나 단일하지 않으면 listener 시작을 거부한다. 정책은 gRPC `Metadata`만 받아
|
||||
Spring Security에 결합되지 않으며, `false` 반환과 policy 예외는 동일한 안정적
|
||||
`UNAUTHENTICATED` 계약으로 끝난다.
|
||||
|
||||
## 에러 매핑 — web `GlobalExceptionHandler` 의 gRPC 형제
|
||||
|
||||
`GrpcExceptionHandlingInterceptor`는 forwarding `ServerCall`의 `close`까지 감싼다. 동기
|
||||
handler throw, listener callback throw, `responseObserver.onError(...)`, raw
|
||||
`StatusRuntimeException`이 모두 같은 sanitizer를 거친다. 서비스 구현은 web 컨트롤러처럼 "그냥
|
||||
던지기만" 하고, 이 인터셉터가 와이어 계약을 단일 소유한다.
|
||||
|
||||
- **와이어 status(coarse)** 는 `GrpcStatusMapper.toStatus(Category)` 가 결정한다(HTTP status 가
|
||||
coarse 인 것과 동형). 정확한 `code`/`category` 는 `Status` trailer `Metadata`(`error-code` /
|
||||
`error-category`)에 실어 클라이언트가 switch 하게 한다.
|
||||
- **`ApiErrorCode` 추출**: feature 예외는 `ApiErrorCarrier`(이 모듈이 제공하는 전송-중립 hook)를
|
||||
구현해 자신의 `ApiErrorCode` 를 노출한다. shared-contract 의 `PersistenceFailureException` /
|
||||
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)도 직접 인식한다.
|
||||
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 status description/trailer 로 노출하고, raw
|
||||
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. 인식되지
|
||||
않은 예외와 raw gRPC status/description은 원래 status를 신뢰하지 않고 `Status.INTERNAL` +
|
||||
`INTERNAL_ERROR`로 폴백한다. 입력 trailers도 폐기한다.
|
||||
|
||||
`Category → Status` 표(설계 스펙 Error Mapping SSOT):
|
||||
|
||||
| `Category` | gRPC `Status` |
|
||||
|---|---|
|
||||
| VALIDATION | INVALID_ARGUMENT |
|
||||
| AUTH | UNAUTHENTICATED |
|
||||
| AUTHZ | PERMISSION_DENIED |
|
||||
| NOT_FOUND | NOT_FOUND |
|
||||
| CONFLICT | ABORTED |
|
||||
| RATE_LIMIT | RESOURCE_EXHAUSTED |
|
||||
| TRANSIENT_DEPENDENCY | UNAVAILABLE |
|
||||
| PERMANENT_DEPENDENCY | INTERNAL |
|
||||
| DATA_INTEGRITY | INTERNAL |
|
||||
| INTERNAL | INTERNAL |
|
||||
|
||||
## 의존성 버전 — strict locking
|
||||
|
||||
Spring Boot BOM 은 `io.grpc:*`/protobuf 버전을 관리하지 않고 이 저장소엔 version catalog 도
|
||||
없다. 그래서 `io.grpc:grpc-bom` + `com.google.protobuf:protobuf-bom` 을 **이 모듈의**
|
||||
`dependencyManagement` 에서 platform 으로 import 한다(루트 `ext.grpcVersion`/`ext.protobufVersion`
|
||||
가 단일 SSOT). 모듈 스코프로 두어 strict per-module lockfile 의 blast radius 를 이 모듈에만
|
||||
가둔다 — 공유 루트 dependencyManagement 블록은 io.grpc-free 로 유지된다.
|
||||
|
||||
## P1 qualification과 P2 유보
|
||||
|
||||
`GrpcSafeActivationTest`는 disabled bean/listener 부재와 설정/인증-policy fail-closed를 검증한다.
|
||||
`GrpcP1BoundaryWireTest`는 실제 loopback ephemeral Netty unary service로 auth 성공/실패,
|
||||
reflection-off, 모든 오류 경로의 안정 code/category 및 sentinel redaction을 검증한다.
|
||||
|
||||
이는 local plaintext unary 경계에 대한 P1 증거이며 production-ready 주장 근거가 아니다.
|
||||
TLS/mTLS, external bind, deadline, streaming/backpressure, generated protobuf 호환성은 P2에서 별도
|
||||
설계·검증해야 한다.
|
||||
@@ -0,0 +1,49 @@
|
||||
// Driving adapter: gRPC API (skeleton machinery, transport-only).
|
||||
//
|
||||
// A SmartLifecycle bean (GrpcServerRunner) owns the io.grpc Netty server, so this module depends on
|
||||
// NO third-party grpc-spring-boot starter (no Spring Boot version coupling). The skeleton compiles
|
||||
// NO protobuf: there is no `com.google.protobuf` plugin and no `.proto` here — health + reflection
|
||||
// come from grpc-services at runtime, and a future consuming feature owns its `.proto`/services.
|
||||
//
|
||||
// io.grpc:* / protobuf versions are NOT managed by the Spring Boot BOM, and this repo has no version
|
||||
// catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root
|
||||
// `ext.grpcVersion` / `ext.protobufVersion` SSOT — this keeps the strict-locking blast radius to
|
||||
// this module (the shared root dependencyManagement block stays io.grpc-free).
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
|
||||
mavenBom "com.google.protobuf:protobuf-bom:${protobufVersion}"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
|
||||
// Keep the direct versions in the outgoing project metadata as well as importing the BOM.
|
||||
// Spring dependency-management constraints are local to this leaf and are not propagated to
|
||||
// a consumer's custom qualification source set.
|
||||
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
|
||||
implementation "io.grpc:grpc-services:${grpcVersion}" // health + reflection
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// The boot test directly builds generated health/reflection protobuf messages. grpc-services
|
||||
// does not expose protobuf-java on its compile API, so keep the narrower test-only declaration.
|
||||
testImplementation "io.grpc:grpc-protobuf:${grpcVersion}"
|
||||
// Wire qualification directly uses ClientCalls/ServerCalls/MetadataUtils without generated stubs.
|
||||
testImplementation "io.grpc:grpc-stub:${grpcVersion}"
|
||||
}
|
||||
|
||||
registerStrictQualificationTest(
|
||||
name: 'grpcTransportQualificationTest',
|
||||
sourceSet: sourceSets.test,
|
||||
requiredClasses: [
|
||||
'dev.caskeleton.adapter.inbound.grpc.GrpcSafeActivationTest',
|
||||
'dev.caskeleton.adapter.inbound.grpc.GrpcP1BoundaryWireTest'
|
||||
],
|
||||
description: 'Runs exact no-skip gRPC conditional transport wire evidence.')
|
||||
@@ -0,0 +1,178 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.api.grpc:proto-google-common-protos:2.41.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.code.gson:gson:2.13.2=runtimeClasspath,spotbugs,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=runtimeClasspath,spotbugs,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.2.1-android=compileClasspath,testCompileClasspath
|
||||
com.google.guava:guava:33.2.1-jre=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:2.8=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf:1.68.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
|
||||
/**
|
||||
* Adapter-level throwable a feature gRPC service throws after mapping a domain exception to a
|
||||
* stable {@link ApiErrorCode} (typically a feature code such as {@code
|
||||
* PortfolioErrorCode.WORKLOG_NOT_FOUND} or a skeleton {@code OperationalError}). It implements the
|
||||
* shared-contract {@link ApiErrorCarrier} hook so the {@link GrpcExceptionHandlingInterceptor}
|
||||
* translates it to the matching gRPC {@code Status} plus {@code code} / {@code category} trailers
|
||||
* through the same single carrier branch that handles the shared-contract infra exceptions.
|
||||
*
|
||||
* <p>This is the gRPC sibling of "the web adapter service just throws and one handler owns the wire
|
||||
* mapping": a feature service does the domain-exception → {@code ApiErrorCode} mapping (its own
|
||||
* concern) and throws this; the transport mapping stays in the interceptor. The supplied message is
|
||||
* server-log-only detail — only {@link #errorCode()} reaches the client.
|
||||
*/
|
||||
public class ApiErrorException extends RuntimeException implements ApiErrorCarrier {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient ApiErrorCode errorCode;
|
||||
|
||||
/**
|
||||
* @param errorCode the classified, client-facing code surfaced on the gRPC status trailers
|
||||
* @param message server-log-only diagnostic detail — never surfaced to the client
|
||||
*/
|
||||
public ApiErrorException(ApiErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
|
||||
/** Applies the caller-supplied authentication policy before a feature RPC can reach its handler. */
|
||||
final class GrpcAuthenticationInterceptor implements ServerInterceptor {
|
||||
|
||||
private final GrpcAuthenticationPolicy authenticationPolicy;
|
||||
private final GrpcStatusMapper statusMapper;
|
||||
|
||||
GrpcAuthenticationInterceptor(
|
||||
GrpcAuthenticationPolicy authenticationPolicy, GrpcStatusMapper statusMapper) {
|
||||
this.authenticationPolicy = authenticationPolicy;
|
||||
this.statusMapper = statusMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall(
|
||||
ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) {
|
||||
if (isAuthenticated(headers)) {
|
||||
return next.startCall(call, headers);
|
||||
}
|
||||
|
||||
call.close(
|
||||
Status.UNAUTHENTICATED.withDescription(OperationalError.UNAUTHENTICATED.code()),
|
||||
statusMapper.trailersFor(OperationalError.UNAUTHENTICATED));
|
||||
return new ServerCall.Listener<>() {};
|
||||
}
|
||||
|
||||
private boolean isAuthenticated(Metadata headers) {
|
||||
try {
|
||||
return authenticationPolicy.isAuthenticated(headers);
|
||||
} catch (RuntimeException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import io.grpc.Metadata;
|
||||
|
||||
/**
|
||||
* Caller-supplied policy that authenticates feature RPC metadata without Spring Security coupling.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GrpcAuthenticationPolicy {
|
||||
|
||||
/**
|
||||
* Returns {@code true} only when the request metadata represents an authenticated caller. A
|
||||
* {@code false} result or policy exception becomes the same stable {@code UNAUTHENTICATED} wire
|
||||
* contract; policy diagnostics never reach the client.
|
||||
*/
|
||||
boolean isAuthenticated(Metadata metadata);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.grpc.ForwardingServerCall.SimpleForwardingServerCall;
|
||||
import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Centralises the gRPC error contract: a feature service just throws or calls {@code onError}, and
|
||||
* this {@link ServerInterceptor} wraps handler/listener throws and every non-OK {@link
|
||||
* ServerCall#close(Status, Metadata)} behind one sanitizer. The result carries a mapped {@link
|
||||
* Status} plus stable {@code code} / {@code category} trailers — the gRPC sibling of the web
|
||||
* adapter's {@code GlobalExceptionHandler}.
|
||||
*
|
||||
* <p>A stable {@link ApiErrorCode} is recognised through the shared-contract {@link
|
||||
* ApiErrorCarrier} hook — implemented by a feature throwable (the gRPC {@link ApiErrorException}
|
||||
* carrying a mapped domain code) and by the shared-contract {@code PersistenceFailureException} /
|
||||
* {@code DependencyFailureException}, so a single {@code instanceof ApiErrorCarrier} branch covers
|
||||
* them all. An unrecognised exception or raw gRPC status maps to {@link Status#INTERNAL} with
|
||||
* {@link OperationalError#INTERNAL_ERROR}. Only the stable code string reaches the client (via the
|
||||
* status description and trailers) — raw descriptions and input trailers, which may carry a
|
||||
* SQLState or upstream detail, are never surfaced.
|
||||
*/
|
||||
public class GrpcExceptionHandlingInterceptor implements ServerInterceptor {
|
||||
|
||||
private final GrpcStatusMapper statusMapper;
|
||||
|
||||
public GrpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) {
|
||||
this.statusMapper = statusMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall(
|
||||
ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) {
|
||||
AtomicBoolean closed = new AtomicBoolean(false);
|
||||
ServerCall<REQT, RESPT> sanitizingCall = sanitizingCall(call, closed);
|
||||
ServerCall.Listener<REQT> delegate;
|
||||
try {
|
||||
delegate = next.startCall(sanitizingCall, headers);
|
||||
} catch (RuntimeException e) {
|
||||
closeWithError(sanitizingCall, e);
|
||||
return new ServerCall.Listener<>() {};
|
||||
}
|
||||
return new SimpleForwardingServerCallListener<>(delegate) {
|
||||
@Override
|
||||
public void onMessage(REQT message) {
|
||||
runGuarded(() -> super.onMessage(message));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHalfClose() {
|
||||
runGuarded(super::onHalfClose);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReady() {
|
||||
runGuarded(super::onReady);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel() {
|
||||
runGuarded(super::onCancel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
runGuarded(super::onComplete);
|
||||
}
|
||||
|
||||
private void runGuarded(Runnable action) {
|
||||
if (closed.get()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException e) {
|
||||
closeWithError(sanitizingCall, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private <REQT, RESPT> ServerCall<REQT, RESPT> sanitizingCall(
|
||||
ServerCall<REQT, RESPT> delegate, AtomicBoolean closed) {
|
||||
return new SimpleForwardingServerCall<>(delegate) {
|
||||
@Override
|
||||
public void close(Status status, Metadata trailers) {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (status.isOk()) {
|
||||
super.close(status, trailers);
|
||||
return;
|
||||
}
|
||||
|
||||
ApiErrorCode code = errorCodeOf(status.getCause());
|
||||
if (code == null) {
|
||||
code = OperationalError.INTERNAL_ERROR;
|
||||
}
|
||||
super.close(
|
||||
statusMapper.toStatus(code.category()).withDescription(code.code()),
|
||||
statusMapper.trailersFor(code));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void closeWithError(ServerCall<?, ?> call, RuntimeException exception) {
|
||||
call.close(Status.fromThrowable(exception).withCause(exception), new Metadata());
|
||||
}
|
||||
|
||||
private static ApiErrorCode errorCodeOf(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
while (current != null) {
|
||||
if (current instanceof ApiErrorCarrier carrier) {
|
||||
return carrier.errorCode();
|
||||
}
|
||||
if (current.getCause() == current) {
|
||||
break;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import io.grpc.BindableService;
|
||||
import io.grpc.protobuf.services.HealthStatusManager;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Wires the gRPC transport machinery only when {@code ca-skeleton.grpc.enabled=true} is explicit.
|
||||
* All collaborators are plain objects composed here, mirroring the clean DI style used across the
|
||||
* skeleton. Feature {@link BindableService} beans are injected via {@link ObjectProvider} and
|
||||
* registered generically by {@link GrpcServerRunner}. See README.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.grpc",
|
||||
name = "enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
@EnableConfigurationProperties(GrpcServerProperties.class)
|
||||
public class GrpcServerConfig {
|
||||
|
||||
@Bean
|
||||
GrpcStatusMapper grpcStatusMapper() {
|
||||
return new GrpcStatusMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GrpcExceptionHandlingInterceptor grpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) {
|
||||
return new GrpcExceptionHandlingInterceptor(statusMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
HealthStatusManager grpcHealthStatusManager() {
|
||||
return new HealthStatusManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GrpcServerRunner grpcServerRunner(
|
||||
ObjectProvider<BindableService> services,
|
||||
ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies,
|
||||
GrpcServerProperties properties,
|
||||
GrpcExceptionHandlingInterceptor exceptionInterceptor,
|
||||
GrpcStatusMapper statusMapper,
|
||||
HealthStatusManager healthStatusManager) {
|
||||
return new GrpcServerRunner(
|
||||
services,
|
||||
authenticationPolicies,
|
||||
properties,
|
||||
exceptionInterceptor,
|
||||
statusMapper,
|
||||
healthStatusManager);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* gRPC server settings bound from {@code ca-skeleton.grpc.*}. Typed configuration only (no
|
||||
* per-module {@code yml}); values live in the composition-root {@code application.yml}, matching
|
||||
* the ca-skeleton config convention. See README for the self-managed-Netty rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.grpc", ignoreUnknownFields = false)
|
||||
@Validated
|
||||
public class GrpcServerProperties {
|
||||
|
||||
/** Whether to start the gRPC server at all. Activation must always be explicit. */
|
||||
private boolean enabled;
|
||||
|
||||
/** TCP port the gRPC server binds to. Set to {@code 0} to bind an ephemeral port (tests). */
|
||||
@Min(0)
|
||||
@Max(65535)
|
||||
private int port = 9090;
|
||||
|
||||
/** Loopback address used by the P1 local-only insecure listener. */
|
||||
@NotBlank private String bindAddress = "127.0.0.1";
|
||||
|
||||
/** Explicit acknowledgement that the enabled P1 listener is plaintext and local-only. */
|
||||
private boolean allowInsecureLocal;
|
||||
|
||||
/** Expose server reflection only when explicitly requested for local development. */
|
||||
private boolean reflectionEnabled;
|
||||
|
||||
/** Seconds to wait for in-flight RPCs to finish on graceful shutdown. */
|
||||
@Min(0)
|
||||
private int shutdownGraceSeconds = 5;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public void setPort(int port) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public String getBindAddress() {
|
||||
return bindAddress;
|
||||
}
|
||||
|
||||
public void setBindAddress(String bindAddress) {
|
||||
this.bindAddress = bindAddress;
|
||||
}
|
||||
|
||||
public boolean isAllowInsecureLocal() {
|
||||
return allowInsecureLocal;
|
||||
}
|
||||
|
||||
public void setAllowInsecureLocal(boolean allowInsecureLocal) {
|
||||
this.allowInsecureLocal = allowInsecureLocal;
|
||||
}
|
||||
|
||||
public boolean isReflectionEnabled() {
|
||||
return reflectionEnabled;
|
||||
}
|
||||
|
||||
public void setReflectionEnabled(boolean reflectionEnabled) {
|
||||
this.reflectionEnabled = reflectionEnabled;
|
||||
}
|
||||
|
||||
public int getShutdownGraceSeconds() {
|
||||
return shutdownGraceSeconds;
|
||||
}
|
||||
|
||||
public void setShutdownGraceSeconds(int shutdownGraceSeconds) {
|
||||
this.shutdownGraceSeconds = shutdownGraceSeconds;
|
||||
}
|
||||
|
||||
@AssertTrue(
|
||||
message = "insecure gRPC requires allow-insecure-local=true and a loopback bind address")
|
||||
public boolean isInsecureLocalConfigurationValid() {
|
||||
return !enabled || (allowInsecureLocal && isLoopbackBindAddress());
|
||||
}
|
||||
|
||||
InetAddress resolvedBindAddress() {
|
||||
try {
|
||||
return InetAddress.getByName(bindAddress);
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalStateException("gRPC bind address cannot be resolved", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLoopbackBindAddress() {
|
||||
if (bindAddress == null || bindAddress.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return resolvedBindAddress().isLoopbackAddress();
|
||||
} catch (IllegalStateException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import io.grpc.BindableService;
|
||||
import io.grpc.InsecureServerCredentials;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.ServerInterceptors;
|
||||
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
|
||||
import io.grpc.protobuf.services.HealthStatusManager;
|
||||
import io.grpc.protobuf.services.ProtoReflectionServiceV1;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/**
|
||||
* Owns the io.grpc Netty {@link Server} lifecycle as a Spring {@link SmartLifecycle} bean — start
|
||||
* on context refresh, graceful shutdown on close. Deliberately avoids any third-party
|
||||
* grpc-spring-boot starter so the skeleton has no Spring Boot version coupling.
|
||||
*
|
||||
* <p>Feature services are discovered generically: every {@link BindableService} bean is registered
|
||||
* behind caller-supplied authentication and the {@link GrpcExceptionHandlingInterceptor}. The
|
||||
* skeleton also registers standard {@code grpc.health.v1} health and, only when explicitly enabled,
|
||||
* v1 server reflection, so it needs no feature {@code .proto}. See README.
|
||||
*/
|
||||
public class GrpcServerRunner implements SmartLifecycle {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GrpcServerRunner.class);
|
||||
|
||||
private final ObjectProvider<BindableService> services;
|
||||
private final ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies;
|
||||
private final GrpcServerProperties properties;
|
||||
private final GrpcExceptionHandlingInterceptor exceptionInterceptor;
|
||||
private final GrpcStatusMapper statusMapper;
|
||||
private final HealthStatusManager healthStatusManager;
|
||||
private volatile Server server;
|
||||
|
||||
public GrpcServerRunner(
|
||||
ObjectProvider<BindableService> services,
|
||||
ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies,
|
||||
GrpcServerProperties properties,
|
||||
GrpcExceptionHandlingInterceptor exceptionInterceptor,
|
||||
GrpcStatusMapper statusMapper,
|
||||
HealthStatusManager healthStatusManager) {
|
||||
this.services = services;
|
||||
this.authenticationPolicies = authenticationPolicies;
|
||||
this.properties = properties;
|
||||
this.exceptionInterceptor = exceptionInterceptor;
|
||||
this.statusMapper = statusMapper;
|
||||
this.healthStatusManager = healthStatusManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (isRunning()) {
|
||||
return;
|
||||
}
|
||||
List<BindableService> featureServices = services.orderedStream().toList();
|
||||
List<GrpcAuthenticationPolicy> policies = authenticationPolicies.orderedStream().toList();
|
||||
if (!featureServices.isEmpty() && policies.size() != 1) {
|
||||
throw new IllegalStateException(
|
||||
"feature gRPC services require exactly one caller-supplied authentication policy");
|
||||
}
|
||||
GrpcAuthenticationPolicy authenticationPolicy =
|
||||
policies.size() == 1 ? policies.getFirst() : null;
|
||||
|
||||
var address = new InetSocketAddress(properties.resolvedBindAddress(), properties.getPort());
|
||||
var builder = NettyServerBuilder.forAddress(address, InsecureServerCredentials.create());
|
||||
var authenticationInterceptor =
|
||||
authenticationPolicy == null
|
||||
? null
|
||||
: new GrpcAuthenticationInterceptor(authenticationPolicy, statusMapper);
|
||||
|
||||
int registered = 0;
|
||||
for (BindableService service : featureServices) {
|
||||
builder.addService(
|
||||
ServerInterceptors.intercept(service, exceptionInterceptor, authenticationInterceptor));
|
||||
registered++;
|
||||
}
|
||||
|
||||
healthStatusManager.setStatus(
|
||||
HealthStatusManager.SERVICE_NAME_ALL_SERVICES, ServingStatus.SERVING);
|
||||
builder.addService(healthStatusManager.getHealthService());
|
||||
if (properties.isReflectionEnabled()) {
|
||||
builder.addService(ProtoReflectionServiceV1.newInstance());
|
||||
}
|
||||
|
||||
try {
|
||||
server = builder.build().start();
|
||||
log.info(
|
||||
"gRPC server started on port {} ({} feature service(s), reflection={})",
|
||||
server.getPort(),
|
||||
registered,
|
||||
properties.isReflectionEnabled());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(
|
||||
"failed to start gRPC server on port " + properties.getPort(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
Server current = this.server;
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
healthStatusManager.enterTerminalState();
|
||||
try {
|
||||
current.shutdown();
|
||||
if (!current.awaitTermination(properties.getShutdownGraceSeconds(), TimeUnit.SECONDS)) {
|
||||
current.shutdownNow();
|
||||
}
|
||||
log.info("gRPC server stopped");
|
||||
} catch (InterruptedException e) {
|
||||
current.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
Server current = this.server;
|
||||
return current != null && !current.isShutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actual bound port — useful when configured with port 0 for tests; {@code -1} when not started.
|
||||
*/
|
||||
public int getListeningPort() {
|
||||
Server current = this.server;
|
||||
return current != null ? current.getPort() : -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
// Start after the web server is up, stop before it during shutdown.
|
||||
return Integer.MAX_VALUE - 1;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
|
||||
/**
|
||||
* Pure translation of the 10-value operational {@link Category} SSOT to an {@link io.grpc.Status},
|
||||
* plus helpers to carry the machine-readable {@code code} / {@code category} on the response
|
||||
* trailer {@link Metadata}. This is the gRPC sibling of the web adapter's error contract: the wire
|
||||
* status (like an HTTP status) is coarse, while the exact {@link ApiErrorCode#code()} and the
|
||||
* category name ride in the trailers for the client to switch on.
|
||||
*
|
||||
* <p>The classification table is fixed by the design spec's Error Mapping section; see README.
|
||||
*/
|
||||
public class GrpcStatusMapper {
|
||||
|
||||
/**
|
||||
* Trailer key carrying the stable {@link ApiErrorCode#code()} (e.g. {@code WORKLOG_NOT_FOUND}).
|
||||
*/
|
||||
static final Metadata.Key<String> CODE_KEY =
|
||||
Metadata.Key.of("error-code", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
/** Trailer key carrying the {@link Category} enum name (e.g. {@code NOT_FOUND}). */
|
||||
static final Metadata.Key<String> CATEGORY_KEY =
|
||||
Metadata.Key.of("error-category", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
/**
|
||||
* Maps an operational {@link Category} to its gRPC {@link Status} (design Error-Mapping table).
|
||||
*/
|
||||
public Status toStatus(Category category) {
|
||||
return switch (category) {
|
||||
case VALIDATION -> Status.INVALID_ARGUMENT;
|
||||
case AUTH -> Status.UNAUTHENTICATED;
|
||||
case AUTHZ -> Status.PERMISSION_DENIED;
|
||||
case NOT_FOUND -> Status.NOT_FOUND;
|
||||
case CONFLICT -> Status.ABORTED;
|
||||
case RATE_LIMIT -> Status.RESOURCE_EXHAUSTED;
|
||||
case TRANSIENT_DEPENDENCY -> Status.UNAVAILABLE;
|
||||
case PERMANENT_DEPENDENCY -> Status.INTERNAL;
|
||||
case DATA_INTEGRITY -> Status.INTERNAL;
|
||||
case INTERNAL -> Status.INTERNAL;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the {@code code} and {@code category} of {@code errorCode} onto a fresh trailer {@link
|
||||
* Metadata}, returned for {@code ServerCall#close(Status, Metadata)}.
|
||||
*/
|
||||
public Metadata trailersFor(ApiErrorCode errorCode) {
|
||||
Metadata trailers = new Metadata();
|
||||
trailers.put(CODE_KEY, errorCode.code());
|
||||
trailers.put(CATEGORY_KEY, errorCode.category().name());
|
||||
return trailers;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Verifies {@link ApiErrorException} carries its {@link dev.caskeleton.shared.error.ApiErrorCode}
|
||||
* through the shared-contract {@link ApiErrorCarrier} hook unchanged, and keeps the diagnostic
|
||||
* message off the carrier surface.
|
||||
*/
|
||||
class ApiErrorExceptionTest {
|
||||
|
||||
@Test
|
||||
void errorCodeRoundTripsThroughTheCarrierHook() {
|
||||
ApiErrorException exception =
|
||||
new ApiErrorException(OperationalError.BAD_PARAMETER, "server-log-only detail");
|
||||
|
||||
assertThat(exception).isInstanceOf(ApiErrorCarrier.class);
|
||||
assertThat(exception.errorCode()).isEqualTo(OperationalError.BAD_PARAMETER);
|
||||
assertThat(exception.getMessage()).isEqualTo("server-log-only detail");
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.MethodDescriptor;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.Status;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Verifies the interceptor translates a synchronous handler exception into a mapped {@code
|
||||
* close(status, trailers)} — recognising the {@link ApiErrorCarrier} feature hook and the
|
||||
* shared-contract {@link PersistenceFailureException}, and falling back to {@link Status#INTERNAL}
|
||||
* for an unrecognised {@link RuntimeException}. Driven with a capturing fake {@link ServerCall}, so
|
||||
* no channel/server is needed.
|
||||
*/
|
||||
class GrpcExceptionHandlingInterceptorTest {
|
||||
|
||||
private final GrpcExceptionHandlingInterceptor interceptor =
|
||||
new GrpcExceptionHandlingInterceptor(new GrpcStatusMapper());
|
||||
|
||||
@Test
|
||||
void mapsApiErrorCarrierToItsCategoryStatusWithTrailers() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(new CarrierException(OperationalError.BAD_PARAMETER));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("BAD_PARAMETER");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("VALIDATION");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsApiErrorExceptionByItsCarriedCode() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(
|
||||
new ApiErrorException(OperationalError.ROUTE_NOT_FOUND, "server-log-only detail"));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.NOT_FOUND);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("ROUTE_NOT_FOUND");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsSharedPersistenceFailureByItsClassifiedCode() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(
|
||||
new PersistenceFailureException(OperationalError.DB_UNAVAILABLE, "08006", null));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.UNAVAILABLE);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("DB_UNAVAILABLE");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsUnknownRuntimeExceptionToInternal() {
|
||||
CapturingServerCall call = closeAfterThrowing(new IllegalStateException("boom"));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.INTERNAL);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("INTERNAL_ERROR");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("INTERNAL");
|
||||
}
|
||||
|
||||
private CapturingServerCall closeAfterThrowing(RuntimeException thrown) {
|
||||
CapturingServerCall call = new CapturingServerCall();
|
||||
ServerCallHandler<String, String> handler =
|
||||
(serverCall, headers) ->
|
||||
new ServerCall.Listener<>() {
|
||||
@Override
|
||||
public void onHalfClose() {
|
||||
throw thrown;
|
||||
}
|
||||
};
|
||||
ServerCall.Listener<String> listener = interceptor.interceptCall(call, new Metadata(), handler);
|
||||
listener.onHalfClose();
|
||||
return call;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-style exception carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
|
||||
*/
|
||||
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private final ApiErrorCode errorCode;
|
||||
|
||||
CarrierException(ApiErrorCode errorCode) {
|
||||
super(errorCode.code());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal {@link ServerCall} that records the {@code close(status, trailers)} arguments. */
|
||||
private static final class CapturingServerCall extends ServerCall<String, String> {
|
||||
private Status status;
|
||||
private Metadata trailers;
|
||||
|
||||
@Override
|
||||
public void request(int numMessages) {}
|
||||
|
||||
@Override
|
||||
public void sendHeaders(Metadata headers) {}
|
||||
|
||||
@Override
|
||||
public void sendMessage(String message) {}
|
||||
|
||||
@Override
|
||||
public void close(Status status, Metadata trailers) {
|
||||
this.status = status;
|
||||
this.trailers = trailers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCancelled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MethodDescriptor<String, String> getMethodDescriptor() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.grpc.BindableService;
|
||||
import io.grpc.CallOptions;
|
||||
import io.grpc.Channel;
|
||||
import io.grpc.ClientInterceptors;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.MethodDescriptor;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerServiceDefinition;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.grpc.reflection.v1.ServerReflectionGrpc;
|
||||
import io.grpc.reflection.v1.ServerReflectionRequest;
|
||||
import io.grpc.reflection.v1.ServerReflectionResponse;
|
||||
import io.grpc.stub.ClientCalls;
|
||||
import io.grpc.stub.MetadataUtils;
|
||||
import io.grpc.stub.ServerCalls;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
class GrpcP1BoundaryWireTest {
|
||||
|
||||
private static final String SERVICE_NAME = "test.p1.Feature";
|
||||
private static final String AUTH_TOKEN = "Bearer p1-test-token";
|
||||
private static final String INVALID_TOKEN_SENTINEL = "invalid-token-secret-sentinel";
|
||||
private static final String HANDLER_SENTINEL = "handler-secret-sentinel";
|
||||
private static final String LISTENER_SENTINEL = "listener-secret-sentinel";
|
||||
private static final String CARRIER_SENTINEL = "carrier-secret-sentinel";
|
||||
private static final String RAW_STATUS_SENTINEL = "raw-status-secret-sentinel";
|
||||
|
||||
private static final Metadata.Key<String> AUTHORIZATION =
|
||||
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
private static final MethodDescriptor<String, String> UNARY_METHOD = unaryMethod("UnaryFeature");
|
||||
private static final MethodDescriptor<String, String> HANDLER_THROW_METHOD =
|
||||
unaryMethod("HandlerThrow");
|
||||
private static final MethodDescriptor<String, String> LISTENER_THROW_METHOD =
|
||||
unaryMethod("ListenerThrow");
|
||||
|
||||
private final ApplicationContextRunner contextRunner =
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(GrpcServerConfig.class, FeatureConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.grpc.enabled=true",
|
||||
"ca-skeleton.grpc.port=0",
|
||||
"ca-skeleton.grpc.bind-address=127.0.0.1",
|
||||
"ca-skeleton.grpc.allow-insecure-local=true",
|
||||
"ca-skeleton.grpc.reflection-enabled=false");
|
||||
|
||||
@Test
|
||||
void missingAuthenticationMetadataIsRejectedWithAStableContract() {
|
||||
withChannel(
|
||||
channel ->
|
||||
assertFailure(
|
||||
channel,
|
||||
UNARY_METHOD,
|
||||
"ok",
|
||||
Status.Code.UNAUTHENTICATED,
|
||||
"UNAUTHENTICATED",
|
||||
"AUTH",
|
||||
null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidAuthenticationMetadataIsRejectedWithoutEchoingIt() {
|
||||
withChannel(
|
||||
channel -> {
|
||||
Metadata headers = new Metadata();
|
||||
headers.put(AUTHORIZATION, "Bearer " + INVALID_TOKEN_SENTINEL);
|
||||
|
||||
assertFailure(
|
||||
attach(channel, headers),
|
||||
UNARY_METHOD,
|
||||
"ok",
|
||||
Status.Code.UNAUTHENTICATED,
|
||||
"UNAUTHENTICATED",
|
||||
"AUTH",
|
||||
INVALID_TOKEN_SENTINEL);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void validAuthenticationMetadataReachesTheFeatureService() {
|
||||
withChannel(
|
||||
channel ->
|
||||
assertThat(unary(authenticated(channel), UNARY_METHOD, "ok"))
|
||||
.isEqualTo("authorized-ok"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reflectionRemainsUnavailableWhenItsIndependentFlagIsFalse() {
|
||||
withChannel(
|
||||
channel -> {
|
||||
Throwable failure = reflectionFailure(channel);
|
||||
|
||||
assertThat(Status.fromThrowable(failure).getCode()).isEqualTo(Status.Code.UNIMPLEMENTED);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void synchronousHandlerThrowUsesTheStableCarrierContract() {
|
||||
withChannel(
|
||||
channel ->
|
||||
assertFailure(
|
||||
authenticated(channel),
|
||||
HANDLER_THROW_METHOD,
|
||||
"ignored",
|
||||
Status.Code.INVALID_ARGUMENT,
|
||||
"BAD_PARAMETER",
|
||||
"VALIDATION",
|
||||
HANDLER_SENTINEL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listenerThrowUsesTheStableCarrierContract() {
|
||||
withChannel(
|
||||
channel ->
|
||||
assertFailure(
|
||||
authenticated(channel),
|
||||
LISTENER_THROW_METHOD,
|
||||
"ignored",
|
||||
Status.Code.NOT_FOUND,
|
||||
"ROUTE_NOT_FOUND",
|
||||
"NOT_FOUND",
|
||||
LISTENER_SENTINEL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void responseObserverCarrierErrorUsesTheStableCarrierContract() {
|
||||
withChannel(
|
||||
channel ->
|
||||
assertFailure(
|
||||
authenticated(channel),
|
||||
UNARY_METHOD,
|
||||
"carrier-error",
|
||||
Status.Code.RESOURCE_EXHAUSTED,
|
||||
"RATE_LIMIT_EXCEEDED",
|
||||
"RATE_LIMIT",
|
||||
CARRIER_SENTINEL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawStatusRuntimeExceptionIsSanitizedToInternal() {
|
||||
withChannel(
|
||||
channel ->
|
||||
assertFailure(
|
||||
authenticated(channel),
|
||||
UNARY_METHOD,
|
||||
"raw-status-error",
|
||||
Status.Code.INTERNAL,
|
||||
"INTERNAL_ERROR",
|
||||
"INTERNAL",
|
||||
RAW_STATUS_SENTINEL));
|
||||
}
|
||||
|
||||
private void withChannel(Consumer<ManagedChannel> assertion) {
|
||||
contextRunner.run(
|
||||
context -> {
|
||||
assertThat(context.getStartupFailure()).isNull();
|
||||
int port = context.getBean(GrpcServerRunner.class).getListeningPort();
|
||||
ManagedChannel channel =
|
||||
ManagedChannelBuilder.forAddress("127.0.0.1", port).usePlaintext().build();
|
||||
try {
|
||||
assertion.accept(channel);
|
||||
} finally {
|
||||
channel.shutdownNow();
|
||||
awaitChannelTermination(channel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void awaitChannelTermination(ManagedChannel channel) {
|
||||
try {
|
||||
assertThat(channel.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("interrupted while closing test channel", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Channel authenticated(Channel channel) {
|
||||
Metadata headers = new Metadata();
|
||||
headers.put(AUTHORIZATION, AUTH_TOKEN);
|
||||
return attach(channel, headers);
|
||||
}
|
||||
|
||||
private static Channel attach(Channel channel, Metadata headers) {
|
||||
return ClientInterceptors.intercept(
|
||||
channel, MetadataUtils.newAttachHeadersInterceptor(headers));
|
||||
}
|
||||
|
||||
private static String unary(
|
||||
Channel channel, MethodDescriptor<String, String> method, String request) {
|
||||
return ClientCalls.blockingUnaryCall(
|
||||
channel, method, CallOptions.DEFAULT.withDeadlineAfter(5, TimeUnit.SECONDS), request);
|
||||
}
|
||||
|
||||
private static void assertFailure(
|
||||
Channel channel,
|
||||
MethodDescriptor<String, String> method,
|
||||
String request,
|
||||
Status.Code expectedStatus,
|
||||
String expectedCode,
|
||||
String expectedCategory,
|
||||
String forbiddenSentinel) {
|
||||
StatusRuntimeException failure =
|
||||
catchThrowableOfType(StatusRuntimeException.class, () -> unary(channel, method, request));
|
||||
|
||||
assertThat(failure).isNotNull();
|
||||
assertThat(failure.getStatus().getCode()).isEqualTo(expectedStatus);
|
||||
assertThat(failure.getStatus().getDescription()).isEqualTo(expectedCode);
|
||||
assertThat(failure.getTrailers()).isNotNull();
|
||||
assertThat(failure.getTrailers().get(GrpcStatusMapper.CODE_KEY)).isEqualTo(expectedCode);
|
||||
assertThat(failure.getTrailers().get(GrpcStatusMapper.CATEGORY_KEY))
|
||||
.isEqualTo(expectedCategory);
|
||||
if (forbiddenSentinel != null) {
|
||||
assertThat(failure.toString()).doesNotContain(forbiddenSentinel);
|
||||
assertThat(failure.getTrailers().toString()).doesNotContain(forbiddenSentinel);
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable reflectionFailure(ManagedChannel channel) {
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
StreamObserver<ServerReflectionRequest> requests =
|
||||
ServerReflectionGrpc.newStub(channel)
|
||||
.serverReflectionInfo(
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(ServerReflectionResponse value) {}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable throwable) {
|
||||
failure.set(throwable);
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
requests.onNext(ServerReflectionRequest.newBuilder().setListServices("").build());
|
||||
requests.onCompleted();
|
||||
try {
|
||||
assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("interrupted while waiting for reflection response", e);
|
||||
}
|
||||
return failure.get();
|
||||
}
|
||||
|
||||
private static MethodDescriptor<String, String> unaryMethod(String methodName) {
|
||||
return MethodDescriptor.<String, String>newBuilder()
|
||||
.setType(MethodDescriptor.MethodType.UNARY)
|
||||
.setFullMethodName(MethodDescriptor.generateFullMethodName(SERVICE_NAME, methodName))
|
||||
.setRequestMarshaller(StringMarshaller.INSTANCE)
|
||||
.setResponseMarshaller(StringMarshaller.INSTANCE)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class FeatureConfiguration {
|
||||
|
||||
@Bean
|
||||
GrpcAuthenticationPolicy grpcAuthenticationPolicy() {
|
||||
return metadata -> AUTH_TOKEN.equals(metadata.get(AUTHORIZATION));
|
||||
}
|
||||
|
||||
@Bean
|
||||
BindableService p1FeatureService() {
|
||||
return () ->
|
||||
ServerServiceDefinition.builder(SERVICE_NAME)
|
||||
.addMethod(
|
||||
UNARY_METHOD,
|
||||
ServerCalls.asyncUnaryCall(
|
||||
(String request, StreamObserver<String> observer) -> {
|
||||
if ("carrier-error".equals(request)) {
|
||||
observer.onError(
|
||||
new ApiErrorException(
|
||||
OperationalError.RATE_LIMIT_EXCEEDED, CARRIER_SENTINEL));
|
||||
return;
|
||||
}
|
||||
if ("raw-status-error".equals(request)) {
|
||||
observer.onError(
|
||||
Status.ABORTED
|
||||
.withDescription(RAW_STATUS_SENTINEL)
|
||||
.asRuntimeException());
|
||||
return;
|
||||
}
|
||||
observer.onNext("authorized-" + request);
|
||||
observer.onCompleted();
|
||||
}))
|
||||
.addMethod(
|
||||
HANDLER_THROW_METHOD,
|
||||
(ServerCallHandler<String, String>)
|
||||
(call, headers) -> {
|
||||
throw new ApiErrorException(
|
||||
OperationalError.BAD_PARAMETER, HANDLER_SENTINEL);
|
||||
})
|
||||
.addMethod(
|
||||
LISTENER_THROW_METHOD,
|
||||
(ServerCallHandler<String, String>)
|
||||
(call, headers) -> {
|
||||
call.request(1);
|
||||
return new ServerCall.Listener<>() {
|
||||
@Override
|
||||
public void onHalfClose() {
|
||||
throw new ApiErrorException(
|
||||
OperationalError.ROUTE_NOT_FOUND, LISTENER_SENTINEL);
|
||||
}
|
||||
};
|
||||
})
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private enum StringMarshaller implements MethodDescriptor.Marshaller<String> {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public InputStream stream(String value) {
|
||||
return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String parse(InputStream stream) {
|
||||
try {
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("failed to decode test request", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.grpc.BindableService;
|
||||
import io.grpc.ServerServiceDefinition;
|
||||
import io.grpc.protobuf.services.HealthStatusManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
class GrpcSafeActivationTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner =
|
||||
new ApplicationContextRunner().withUserConfiguration(GrpcServerConfig.class);
|
||||
|
||||
@Test
|
||||
void defaultsKeepTheTransportAndReflectionDisabled() {
|
||||
GrpcServerProperties properties = new GrpcServerProperties();
|
||||
|
||||
assertThat(properties.isEnabled()).isFalse();
|
||||
assertThat(properties.isReflectionEnabled()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingActivationPropertyCreatesNoGrpcRuntimeBeansOrListener() {
|
||||
contextRunner
|
||||
.withPropertyValues("ca-skeleton.grpc.port=0")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).doesNotHaveBean(GrpcServerProperties.class);
|
||||
assertThat(context).doesNotHaveBean(GrpcServerRunner.class);
|
||||
assertThat(context).doesNotHaveBean(HealthStatusManager.class);
|
||||
assertThat(context).doesNotHaveBean(GrpcExceptionHandlingInterceptor.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledTransportRequiresAnExplicitLocalInsecureOverride() {
|
||||
contextRunner
|
||||
.withPropertyValues("ca-skeleton.grpc.enabled=true", "ca-skeleton.grpc.port=0")
|
||||
.run(context -> assertRootCauseContains(context.getStartupFailure(), "insecure"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void insecureTransportRejectsANonLoopbackBindAddress() {
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.grpc.enabled=true",
|
||||
"ca-skeleton.grpc.port=0",
|
||||
"ca-skeleton.grpc.bind-address=0.0.0.0",
|
||||
"ca-skeleton.grpc.allow-insecure-local=true")
|
||||
.run(context -> assertRootCauseContains(context.getStartupFailure(), "loopback"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledTransportRejectsAPortOutsideTheTcpRange() {
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.grpc.enabled=true",
|
||||
"ca-skeleton.grpc.port=65536",
|
||||
"ca-skeleton.grpc.bind-address=127.0.0.1",
|
||||
"ca-skeleton.grpc.allow-insecure-local=true")
|
||||
.run(context -> assertRootCauseContains(context.getStartupFailure(), "port"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledTransportRejectsANegativeShutdownGrace() {
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.grpc.enabled=true",
|
||||
"ca-skeleton.grpc.port=0",
|
||||
"ca-skeleton.grpc.bind-address=127.0.0.1",
|
||||
"ca-skeleton.grpc.allow-insecure-local=true",
|
||||
"ca-skeleton.grpc.shutdown-grace-seconds=-1")
|
||||
.run(
|
||||
context ->
|
||||
assertRootCauseContains(context.getStartupFailure(), "shutdownGraceSeconds"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void featureServiceRequiresACallerSuppliedAuthenticationPolicy() {
|
||||
contextRunner
|
||||
.withUserConfiguration(FeatureServiceConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.grpc.enabled=true",
|
||||
"ca-skeleton.grpc.port=0",
|
||||
"ca-skeleton.grpc.bind-address=127.0.0.1",
|
||||
"ca-skeleton.grpc.allow-insecure-local=true")
|
||||
.run(context -> assertRootCauseContains(context.getStartupFailure(), "authentication"));
|
||||
}
|
||||
|
||||
private static void assertRootCauseContains(Throwable failure, String expected) {
|
||||
assertThat(failure).isNotNull();
|
||||
Throwable rootCause = failure;
|
||||
while (rootCause.getCause() != null) {
|
||||
rootCause = rootCause.getCause();
|
||||
}
|
||||
assertThat(rootCause).hasMessageContaining(expected);
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class FeatureServiceConfiguration {
|
||||
|
||||
@Bean
|
||||
BindableService featureService() {
|
||||
return () -> ServerServiceDefinition.builder("test.Feature").build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import io.grpc.health.v1.HealthCheckRequest;
|
||||
import io.grpc.health.v1.HealthCheckResponse;
|
||||
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
|
||||
import io.grpc.health.v1.HealthGrpc;
|
||||
import io.grpc.reflection.v1.ServerReflectionGrpc;
|
||||
import io.grpc.reflection.v1.ServerReflectionRequest;
|
||||
import io.grpc.reflection.v1.ServerReflectionResponse;
|
||||
import io.grpc.reflection.v1.ServiceResponse;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
/**
|
||||
* Boots the skeleton gRPC machinery in a real Spring context on an ephemeral port ({@code
|
||||
* ca-skeleton.grpc.port=0}) with ZERO feature services and proves it stands up a working surface:
|
||||
* the {@link GrpcServerRunner} SmartLifecycle starts, the standard {@code grpc.health.v1} health
|
||||
* service reports SERVING, and v1 server reflection lists the built-in services. A real Netty
|
||||
* channel exercises the wire, so this is a genuine transport smoke test, not a wiring mock.
|
||||
*/
|
||||
class GrpcServerRunnerBootTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner =
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(GrpcServerConfig.class)
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.grpc.enabled=true",
|
||||
"ca-skeleton.grpc.port=0",
|
||||
"ca-skeleton.grpc.bind-address=127.0.0.1",
|
||||
"ca-skeleton.grpc.allow-insecure-local=true",
|
||||
"ca-skeleton.grpc.reflection-enabled=true");
|
||||
|
||||
@Test
|
||||
void skeletonServerStartsAndServesHealthAndReflectionWithNoFeatures() {
|
||||
contextRunner.run(
|
||||
context -> {
|
||||
GrpcServerRunner runner = context.getBean(GrpcServerRunner.class);
|
||||
assertThat(runner.isRunning()).isTrue();
|
||||
|
||||
int port = runner.getListeningPort();
|
||||
assertThat(port).isGreaterThan(0);
|
||||
|
||||
ManagedChannel channel =
|
||||
ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build();
|
||||
try {
|
||||
HealthCheckResponse health =
|
||||
HealthGrpc.newBlockingStub(channel).check(HealthCheckRequest.newBuilder().build());
|
||||
assertThat(health.getStatus()).isEqualTo(ServingStatus.SERVING);
|
||||
|
||||
assertThat(listServicesViaReflection(channel))
|
||||
.contains("grpc.health.v1.Health", "grpc.reflection.v1.ServerReflection");
|
||||
} finally {
|
||||
channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<String> listServicesViaReflection(ManagedChannel channel)
|
||||
throws InterruptedException {
|
||||
List<String> services = new ArrayList<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||
|
||||
StreamObserver<ServerReflectionRequest> requests =
|
||||
ServerReflectionGrpc.newStub(channel)
|
||||
.serverReflectionInfo(
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(ServerReflectionResponse response) {
|
||||
for (ServiceResponse service :
|
||||
response.getListServicesResponse().getServiceList()) {
|
||||
services.add(service.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
error.set(t);
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
requests.onNext(ServerReflectionRequest.newBuilder().setListServices("").build());
|
||||
requests.onCompleted();
|
||||
|
||||
assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(error.get()).isNull();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import java.util.EnumSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
/**
|
||||
* Pins the 10-value {@link Category} → {@link Status} classification table (design Error-Mapping)
|
||||
* and the {@code code} / {@code category} trailer helper. One assertion per Category value guards
|
||||
* against a silent remap on a Spring/grpc upgrade.
|
||||
*/
|
||||
class GrpcStatusMapperTest {
|
||||
|
||||
private final GrpcStatusMapper mapper = new GrpcStatusMapper();
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"VALIDATION,INVALID_ARGUMENT",
|
||||
"AUTH,UNAUTHENTICATED",
|
||||
"AUTHZ,PERMISSION_DENIED",
|
||||
"NOT_FOUND,NOT_FOUND",
|
||||
"CONFLICT,ABORTED",
|
||||
"RATE_LIMIT,RESOURCE_EXHAUSTED",
|
||||
"TRANSIENT_DEPENDENCY,UNAVAILABLE",
|
||||
"PERMANENT_DEPENDENCY,INTERNAL",
|
||||
"DATA_INTEGRITY,INTERNAL",
|
||||
"INTERNAL,INTERNAL",
|
||||
})
|
||||
void mapsEachCategoryToItsGrpcStatusCode(Category category, Status.Code expected) {
|
||||
assertThat(mapper.toStatus(category).getCode()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void coversEveryCategoryValue() {
|
||||
// Fails the moment a new Category is added without a mapping decision (switch is exhaustive).
|
||||
for (Category category : EnumSet.allOf(Category.class)) {
|
||||
assertThat(mapper.toStatus(category)).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void trailersCarryStableCodeAndCategoryName() {
|
||||
Metadata trailers = mapper.trailersFor(OperationalError.RATE_LIMIT_EXCEEDED);
|
||||
|
||||
assertThat(trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("RATE_LIMIT_EXCEEDED");
|
||||
assertThat(trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("RATE_LIMIT");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
# adapter:inbound:web — inbound HTTP adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-web`
|
||||
- Gradle path: `:adapter:inbound:web`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:web:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.web`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- HTTP controllers.
|
||||
- Request/response DTOs.
|
||||
- Request DTO to application command mapping.
|
||||
- Authentication, validation, error mapping, filters, and web/security settings.
|
||||
- Sanitized request correlation context exposed through application-owned `CorrelationIdPort`.
|
||||
- Transport-owned OpenAPI customization that keeps `ApiError.details` as `type: object` without
|
||||
leaking Swagger dependencies into `shared-contract`.
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`
|
||||
- `:domain-core`
|
||||
- `:shared-contract`
|
||||
- Spring Web/Security/Validation dependencies.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Direct dependency on `adapter-persistence` or `adapter-outbound`.
|
||||
- Direct repository or JPA entity access from controllers.
|
||||
- Core business rules in controller, filter, config, mapper, or settings code.
|
||||
- DTO leakage into application or domain.
|
||||
|
||||
## Boundary validation & mapper contract
|
||||
|
||||
`feature-boundary-validation-mapping-contract` (LLM Wiki branch note) fixes the
|
||||
behaviour at this layer's boundaries. The repo-level guardrails (ArchUnit +
|
||||
Jackson config + handler) only catch the static violations — the contract below
|
||||
also drives the runtime patterns reference implementations must follow.
|
||||
|
||||
- **B1 — Jackson policy at the request boundary.** `spring.jackson.deserialization`
|
||||
pins `fail-on-unknown-properties`, `fail-on-null-for-primitives`,
|
||||
`fail-on-ignored-properties` to `true` and `read-unknown-enum-values-as-null`
|
||||
to `false`. Do NOT undo this per-DTO with class-level
|
||||
`@JsonIgnoreProperties(ignoreUnknown = true)` — ArchUnit rule
|
||||
`request_dtos_do_not_silence_unknown_fields` blocks it. Use wrapper types
|
||||
(`Integer`, `Long`, `Boolean`, `Optional<T>`) in request records so JSON
|
||||
`null` cannot become primitive `0`.
|
||||
- **B2 — PATCH semantics.** Do not adopt RFC 7396 `application/merge-patch+json`
|
||||
(`null = deletion`). PATCH endpoints must distinguish *absent* (no change),
|
||||
*explicit null* (clear field), and *value* (replace). Use
|
||||
`org.openapitools:jackson-databind-nullable` (`JsonNullable<T>`) or
|
||||
`Optional<T>` wrappers on request records.
|
||||
- **B3 — Mapper-internal failures.** Map record canonical-constructor
|
||||
`IllegalArgumentException`, MapStruct generated NPE, ACL normalization
|
||||
failures, etc. by throwing `MappingException` (sample implementation in
|
||||
`sample-portfolio`); the global handler routes it to `MAPPING_FAILED` (HTTP 400),
|
||||
never to `BAD_PARAMETER` or `INTERNAL_ERROR`. Plain `IllegalArgumentException`
|
||||
remains `BAD_PARAMETER` for non-mapper callers.
|
||||
- **B4 — Validation layering.** Class-level Bean Validation constraints belong
|
||||
to the *syntax* layer (request DTO). Domain invariants belong to
|
||||
`application-core` / `domain-core`. Use `@GroupSequence(...)` to short-circuit
|
||||
invariant evaluation when syntax fails. Keep `@Valid` cascade depth ≤ 3.
|
||||
- **B5 — Polymorphic deserialization.** Calling
|
||||
`ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping()` or
|
||||
referencing `LaissezFaireSubTypeValidator` is the CVE-2019-14379 RCE entry
|
||||
point and is blocked by ArchUnit (`no_jackson_laissez_faire_subtype_validator`,
|
||||
`no_jackson_enable_default_typing_call`). Sealed `Command` types must use
|
||||
`@JsonTypeInfo(use = NAME)` + `@JsonSubTypes`, or a
|
||||
`BasicPolymorphicTypeValidator` allowlist.
|
||||
- **B6 — Virtual thread context propagation.** With
|
||||
`spring.threads.virtual.enabled=true`, do not use `InheritableThreadLocal`
|
||||
(ArchUnit rule `no_inheritable_thread_local`). Filters and interceptors must
|
||||
propagate `requestId` / `traceId` via SLF4J 2.0+ MDC or
|
||||
`RequestContextHolder`.
|
||||
- **B7 — Outbound ACL mapper scope.** Outbound HTTP / messaging adapter
|
||||
responses must pass through an ACL mapper (normalization, masking, public
|
||||
field selection) before reaching `application-core` or `domain-core` — the
|
||||
same boundary contract as inbound. Raw external response types must not leak
|
||||
into `domain-core`.
|
||||
- **B8 — Bulk endpoint partial success.** Envelope `success = true` only when
|
||||
every item succeeded. Partial failure responds with `success = false` +
|
||||
`error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` (per-item array) —
|
||||
a different shape from the single-item endpoint. Document the shape divergence
|
||||
in the OpenAPI spec.
|
||||
|
||||
Domain `@RestControllerAdvice` in a consuming module must be annotated
|
||||
`@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this
|
||||
module's base `GlobalExceptionHandler`), because the base handler's catch-all
|
||||
`@ExceptionHandler(Exception.class)` would otherwise resolve domain exceptions
|
||||
to `INTERNAL_ERROR`. See `sample-portfolio`'s `DomainExceptionHandler` for the
|
||||
pattern.
|
||||
|
||||
The base operational handler (`error/GlobalExceptionHandler`), the error-code
|
||||
contract (`dev.caskeleton.shared.error.ApiErrorCode` + `OperationalError`), the
|
||||
`error/ErrorResponseFactory`, and the `envelope/EnvelopeBodyAdvice` now live in
|
||||
production modules (`adapter:inbound:web` / `shared-contract`), so the running application
|
||||
provides them without depending on `sample-portfolio`. Domain-specific exception
|
||||
handlers and error codes live in the consuming module (see sample's
|
||||
`DomainExceptionHandler` / `PortfolioErrorCode`).
|
||||
|
||||
## Schema / serialization contract
|
||||
|
||||
`feature-schema-serialization-contract` (LLM Wiki branch note) fixes the
|
||||
*response producer* side of the wire contract — the sibling of the B1 *request
|
||||
consumer* policy above. The deserialization switches (B1) and the
|
||||
null/empty/missing 3-state (`Patch<T>` + `JsonNullable`, B2) already cover the
|
||||
inbound side; the rules below cover the outbound side. The Jackson properties
|
||||
live in `app-bootstrap` (`application.yml` `spring.jackson.serialization.*` /
|
||||
`spring.jackson.generator.*`); ArchUnit + effective-config tests live in
|
||||
`app-bootstrap` (`JacksonSerializationPolicyTest`, `no_bigdecimal_double_constructor`).
|
||||
|
||||
- **S1 — Date / time / timezone (D2).** `WRITE_DATES_AS_TIMESTAMPS=false` is
|
||||
pinned, so `java.time` values serialize as ISO-8601 strings via `JavaTimeModule`
|
||||
(`OffsetDateTime` → `"...Z"`, `LocalDate` → `"YYYY-MM-DD"`), never a numeric
|
||||
epoch or `[y,m,d,...]` array. Server timezone is **UTC**: emit instants as
|
||||
`OffsetDateTime`/`Instant` with a `Z` offset. Use `LocalDate` only for
|
||||
date-only calendar fields. Do **not** put timezone-less `LocalDateTime` on a
|
||||
response DTO — it serializes without an offset and breaks the contract.
|
||||
- **S2 — Money / BigDecimal (D3).** Default scale 2, rounding `HALF_UP` unless
|
||||
the domain documents otherwise (KRW/JPY = scale 0 with a schema note).
|
||||
`WRITE_BIGDECIMAL_AS_PLAIN=true` is pinned so values never serialize in
|
||||
scientific notation. Pick **one** JSON representation per API and state it in
|
||||
the OpenAPI schema: **string** (`@JsonSerialize(using = ToStringSerializer.class)`)
|
||||
for public / financial endpoints (client parses, no precision loss), or
|
||||
**number + plain** for internal service-to-service endpoints. Never rely on
|
||||
the default — decide at endpoint design time.
|
||||
- **S3 — `new BigDecimal(double)` is banned.** The `double`/`float` constructors
|
||||
capture binary floating-point error (`new BigDecimal(0.1)` ≠ `0.1`). Build from
|
||||
a `String` (`new BigDecimal("0.1")`) or `BigDecimal.valueOf(double)`. Enforced
|
||||
by the `no_bigdecimal_double_constructor` ArchUnit rule (D3 / SBMS-C3).
|
||||
- **S4 — Enum / null·empty·missing.** Request-side unknown enum →
|
||||
`VALIDATION_FAILED` (B1 `read-unknown-enum-values-as-null=false`); legacy values
|
||||
map through an explicit adapter, never a silent fallback. The
|
||||
absent / explicit-null / value distinction is owned by the inbound web mapper
|
||||
(Controller DTO → Command), expressed with `Patch<T>` (B2); `domain-core` and
|
||||
`application-core` receive the already-resolved 3-state, never a wire type.
|
||||
- **S5 — Out of this branch's scope.** OpenAPI drift enforcement (D5) is owned by
|
||||
the verification suite / api-contract-baseline; removed-field-reuse ban tooling
|
||||
(D6, `x-removed-fields` vs markdown catalog) is `needs-confirmation`; Avro
|
||||
Schema Registry for outbox/event (D7) and response field rename/versioning
|
||||
(`feature-api-compatibility-deprecation-contract`) are separate branches.
|
||||
|
||||
## Business rule validation contract
|
||||
|
||||
`feature-business-rule-validation-contract` (LLM Wiki branch note) fixes **which
|
||||
rule is validated at which boundary**, so "validation" does not collapse into the
|
||||
controller DTO or a DB constraint. It sits on top of the boundary/mapping contract
|
||||
above and is enforced by ArchUnit + contract tests (not new runtime mechanism).
|
||||
|
||||
| Layer | Owner | Validates | `error.category` | Enforced by |
|
||||
|---|---|---|---|---|
|
||||
| syntax / shape | `adapter:inbound:web` request DTO (`@Valid` / `jakarta.validation`) | request shape, types, required fields | `VALIDATION` | `validation_constraints_stay_at_web_boundary` ArchUnit rule |
|
||||
| use case policy | `application-core` | authorization, cross-aggregate policy, state preconditions | `AUTHZ` / `CONFLICT` | `BusinessRuleValidationContractTest` |
|
||||
| domain invariant | `domain-core` model / value object **constructor** | business invariants (e.g. end ≥ start) | `CONFLICT` / `VALIDATION` | domain unit tests (e.g. `PeriodTest`) — constructor is the sole, immutable construction path |
|
||||
| persistence integrity | `adapter-persistence` (translator owned by `feature-persistence-failure-baseline`) | unique / FK / check / serialization | `DATA_INTEGRITY` / `CONFLICT` | `BusinessRuleValidationContractTest` + leak test |
|
||||
|
||||
- **C1 — Validation annotations stay at the web boundary.** `jakarta.validation`
|
||||
(`@NotNull`, `@Valid`, …) must appear only in `adapter:inbound:web`. `domain-core` and
|
||||
`application-core` express invariants and policy as plain Java. The
|
||||
`validation_constraints_stay_at_web_boundary` ArchUnit rule fails the build if a
|
||||
Bean Validation annotation leaks into `..domain..` or `..application..`.
|
||||
- **C2 — Business invariants live in the domain, un-bypassable.** Enforce invariants
|
||||
in the value-object / entity **constructor** (the sole construction path) and keep
|
||||
the type immutable, so no application-service or persistence path can hand out an
|
||||
invariant-violating instance. A DB constraint is a backstop, never the only check
|
||||
(Forbidden: "DB constraint as only invariant").
|
||||
- **C3 / C7 / D9 — Persistence integrity maps to an operational error, leak-free.** A
|
||||
unique/FK/check/serialization failure maps to `DATA_INTEGRITY` / `CONFLICT` with a
|
||||
**client-safe message only**. The raw SQL, constraint/index name, SQLState code,
|
||||
exception class, and stack frame must never reach `error.message` or
|
||||
`error.details`. The base `GlobalExceptionHandler` catch-all already replaces the
|
||||
message with `"Internal server error"` and emits `null` details; the
|
||||
category-correct mapping (23505 → `CONFLICT/DB_UNIQUE_VIOLATION`, …) is owned by
|
||||
`feature-persistence-failure-baseline`'s persistence-adapter translator.
|
||||
- **C8 — Duplicate validation needs a canonical owner.** The same rule MAY be
|
||||
pre-checked at another layer for UX / performance (e.g. an application pre-check
|
||||
mirroring a DB unique constraint), but the **canonical owner** of the rule must be
|
||||
named in a code comment or the relevant `CLAUDE.md`. A duplicate validator with no
|
||||
documented owner is a review failure (silent contradiction risk). This is a process
|
||||
gate (PR review), not an automated rule — `needs-confirmation` until an owner-marker
|
||||
annotation is justified.
|
||||
|
||||
Out of this branch's scope (cross-referenced, not re-implemented here): the
|
||||
SQLState→code 9-row matrix and the `DataAccessException` translator
|
||||
(`feature-persistence-failure-baseline`); the Jackson B1/B2 request-boundary switches
|
||||
and mapper sentinel (`feature-boundary-validation-mapping-contract`); the envelope,
|
||||
`Category` enum, and `OperationalError` codes
|
||||
(`feature-operational-error-observability-foundation`).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:web:test --console=plain
|
||||
```
|
||||
@@ -0,0 +1,463 @@
|
||||
# adapter-web — 설계 결정 참조
|
||||
|
||||
인바운드 HTTP / 보안 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.web`.
|
||||
|
||||
허용/금지 의존, 경계 계약(B1~B8), 스키마/직렬화 계약(S1~S5), 비즈니스 규칙 검증 계약(C1~C8),
|
||||
테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서
|
||||
덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할
|
||||
때 본다. 아래 설명은 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로
|
||||
풀어 둔다.
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI contract stabilization
|
||||
|
||||
Springdoc 3 represents an untyped Java `Object` as an unconstrained OAS 3.1 schema (`{}`).
|
||||
`OpenApiContractConfig` owns the transport-specific correction for the shared `ApiError.details`
|
||||
field and publishes it as `type: object`. This preserves the committed HTTP contract without adding
|
||||
Swagger annotations or dependencies to `shared-contract`. Real-server OpenAPI tests import this
|
||||
production configuration and compare the result with the committed snapshot.
|
||||
|
||||
---
|
||||
|
||||
## auth — 인증 (OIDC resource server)
|
||||
|
||||
### SecurityConfig
|
||||
- **Spring Security 기본 Cache-Control writer 비활성화.** 이 모듈이
|
||||
HTTP cache 헤더 정책을 소유한다(`CacheControlFilter` 가 `Cache-Control: no-store` + `Vary` 방출).
|
||||
헤더의 단일·결정적 소유자를 보장하기 위해 Spring Security 자체의 기본 writer 를 끈다.
|
||||
- **AuthN/AuthZ 분류기를 `exceptionHandling` 과 `oauth2ResourceServer` 양쪽에 설정.** entry point 는
|
||||
missing-token(authorization-layer)과 invalid-token(bearer-filter-layer) 실패를, access-denied
|
||||
handler 는 403 을 담당한다. 두 곳 모두에 설정해야 bearer filter 와 authorization filter 가 동일한
|
||||
Envelope writer 로 귀결된다.
|
||||
|
||||
### JwtDecoderConfig
|
||||
- Spring Boot auto-config decoder 를 대체해 validator chain 을 기본값 의존이 아닌 **명시적 구성**으로 만든다.
|
||||
- **D2 — clock skew 60s 명시 고정**(`JwtTimestampValidator`). framework 기본값에 의존하면 Spring
|
||||
업그레이드로 기본값이 바뀔 때 silent-drift 위험이 있어 여기서 못박는다.
|
||||
- **D4 — issuer 검증**(`SecuritySettings.issuerUri()`). **D3 — audience 검증**(`SecuritySettings.audience()`),
|
||||
단 blank audience 면 검사 건너뜀(기존 settings 계약과 일치).
|
||||
- **JWKS lazy discovery** (`SupplierJwtDecoder`): 기동 시 IdP 가 reachable 일 필요가 없고, 첫 decode
|
||||
시점에 issuer-uri/`.well-known` 네트워크 호출이 일어난다(Spring Boot auto-config 와 동일한 lazy 동작).
|
||||
lazy 초기화 중 외부 discovery/JWKS I/O 실패는 필터 밖 runtime exception으로 탈출시키지 않고
|
||||
`AUTH_JWKS_UNAVAILABLE`로 분류하며, 비-I/O 초기화 실패는 `INTERNAL_AUTH_MISCONFIGURATION`으로
|
||||
fail-closed 한다. 두 carrier 모두 고정 진단만 가지며 원격 응답/URL은 공개 응답에 넣지 않는다.
|
||||
- **Minimal 결정**: JWKS cache TTL 과 unknown-kid rate-limit 은 override 하지 않는다. 정확한 수치는
|
||||
IdP-side token TTL 에 달린 NEEDS_CONTEXT 라 Nimbus/Spring 기본값을 쓰고 문서로만 남긴다.
|
||||
- `jwtValidator` 가 package-private + static 인 이유: 네트워크/IdP 의존 없이 단위 테스트 가능하게 하려고.
|
||||
- audience validator 의 오류 description(`"The aud claim is not valid"`)은 `SecurityErrorClassifier` 의
|
||||
"aud claim" 휴리스틱과 매칭되어 `AUTH_AUDIENCE_MISMATCH` 로 분류되도록 **의도적으로 맞춘 문자열 계약**이다.
|
||||
|
||||
### JwtToAuthenticatedPrincipalConverter
|
||||
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
|
||||
`ObjectOutputStream` 으로 round-trip 되지 않는다. Redis session mode에서도 아래 primitive snapshot
|
||||
repository가 `Authentication` 객체 그래프를 저장하지 않는다.
|
||||
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
|
||||
|
||||
### JWT / Redis session 상호배타 모드
|
||||
|
||||
`ca-skeleton.security.auth-mode=jwt|redis-session`은 하나만 선택한다. JWT mode는 stateless이고
|
||||
CSRF/session repository를 만들지 않는다. Redis session mode는 `Secure`, `HttpOnly`, host-only
|
||||
session cookie, `SameSite=Lax`, cookie/header CSRF와 `migrateSession` fixation 방어를 함께 켠다.
|
||||
|
||||
기본 `HttpSessionSecurityContextRepository`는 Spring Security 객체 전체를 session attribute에 넣어
|
||||
outbound session codec의 primitive allowlist를 깨므로 사용하지 않는다.
|
||||
`PrimitiveSessionSecurityContextRepository`가 `AuthenticatedPrincipal`의 bounded
|
||||
principal/email/roles/authorities만 versioned `byte[]` snapshot으로 저장한다. credential, bearer/JWT,
|
||||
arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지 않는다. foreign principal이나
|
||||
손상·초과 snapshot은 인증 없음으로 fail closed한다. 실제 security filter save/restore 테스트가 다음
|
||||
요청에서 principal과 authorities가 복원되고 session에는 primitive snapshot만 남는 것을 검증한다.
|
||||
|
||||
응답 본문 flush/redirect/error가 새 session보다 먼저 commit되지 않도록 repository가 Spring Security의
|
||||
commit-aware response wrapper 계약을 구현한다. 또한 이 모듈은 HTML 로그인 복귀용 request cache를
|
||||
사용하지 않는 API 경계이므로 request cache를 명시적으로 비활성화한다. 따라서 미인증 요청이
|
||||
`DefaultSavedRequest` 같은 framework object를 session에 넣지 않는다. app-bootstrap의
|
||||
`redisSessionHttpIntegrationTest`가 TLS/ACL Redis와 서로 다른 세 개의 web context를 사용해 생성,
|
||||
복구, logout tombstone, stale save 거부, 장애 시 controller 이전 fail-closed를 검증한다.
|
||||
|
||||
### SecurityErrorClassifier
|
||||
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
|
||||
선언한 세분화 코드를 방출한다.
|
||||
- **메커니즘 & 트레이드오프**: Spring Security 는 JWT 실패에 단일 typed reason 을 노출하지 않으므로,
|
||||
classifier 가 예외 그래프와 validator/Nimbus 메시지 텍스트를 검사한다. 매핑:
|
||||
- missing token → `InsufficientAuthenticationException` → `AUTH_TOKEN_MISSING`
|
||||
- claim validators(`JwtValidationException`) → description 에 따라 `AUTH_TOKEN_EXPIRED` /
|
||||
`AUTH_ISSUER_MISMATCH` / `AUTH_AUDIENCE_MISMATCH`
|
||||
- decode/signature/unknown-kid(`BadJwtException`/`JwtException` cause) →
|
||||
`AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_MALFORMED` / `AUTH_KID_UNKNOWN`
|
||||
- JWKS endpoint 장애 → `AUTH_JWKS_UNAVAILABLE` (503, transient)
|
||||
- 텍스트 휴리스틱은 의도적으로 **좁고 순서가 있다**. 매핑되지 않은 실패는 500 이 아니라 안전한
|
||||
`AUTH_TOKEN_MALFORMED`(401)로 폴백한다.
|
||||
- `AUTHZ_TENANT_MISMATCH` 는 여기서 추론 불가 — application-layer 의 cross-tenant 결정이며, 일반 `AccessDeniedException` 에는 `AUTHZ_INSUFFICIENT_PERMISSION`
|
||||
만 방출한다.
|
||||
|
||||
### AuthErrorResponseWriter
|
||||
- **토큰/PII 리댁션.** 응답 본문엔 해당
|
||||
코드의 일반 `client_safe_message` 만 담고, 원시 예외 텍스트·`Authorization` 헤더·issuer·audience 는
|
||||
절대 포함하지 않는다. 로그 라인엔 code/category/요청 path 만 기록하고 bearer token 은 절대 로깅하지
|
||||
않는다(leak 테스트가 강제하는 계약). 전체 로그 마스킹 필터는 별도 log-management 영역에서 다룬다.
|
||||
- **WWW-Authenticate(RFC 9110 §15.5.2).** 401 응답은 반드시 WWW-Authenticate 헤더를 갖되, `error_description`
|
||||
으로 issuer/token 세부가 새지 않도록 최소한으로 유지한다.
|
||||
|
||||
### EnvelopeAuthenticationEntryPoint
|
||||
- AuthN matrix 구현(인증 실패를 세분화 `OperationalError` 로 분류).
|
||||
- resource-server 인증 실패는 filter layer(`BearerTokenAuthenticationFilter` / `ExceptionTranslationFilter`)에서
|
||||
처리되어 `@RestControllerAdvice` 에 도달하지 않는다. 따라서 세분화 분류는 `GlobalExceptionHandler` 가
|
||||
아니라 반드시 이 entry point 에 위치해야 한다.
|
||||
|
||||
### EnvelopeAccessDeniedHandler
|
||||
- AuthN/AuthZ decision matrix 의 AuthZ 분기(유효 토큰 + 권한 부족 →
|
||||
`AUTHZ_INSUFFICIENT_PERMISSION` 403).
|
||||
- `AUTHZ_TENANT_MISMATCH` 는 application-layer 의 cross-tenant 결정이라
|
||||
일반 Spring `AccessDeniedException` 으로는 추론 불가 — 여기서 방출하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## authz — 인가 (`@RequiresPermission` 강제)
|
||||
|
||||
### MethodSecurityConfig
|
||||
- `RequiresPermission` 강제 지점을 Spring method security 에 배선한다.
|
||||
- `@EnableMethodSecurity(prePostEnabled = false)` — method-security 인프라는 켜되 `@PreAuthorize`/
|
||||
`@PostAuthorize` 인터셉터는 등록하지 않는다(의도적). 컨텍스트 내 유일한 authorization advice 가 아래
|
||||
커스텀 advisor 가 되게 하기 위함.
|
||||
- 이 선택이 **애플리케이션 계층을 Spring Security 애너테이션으로부터 자유롭게 유지(D1)**: 유스케이스는
|
||||
프레임워크 독립적 plain 애너테이션 `RequiresPermission` 만 선언하고 Spring-aware 강제는 이 어댑터가 공급.
|
||||
- advisor 는 `ROLE_INFRASTRUCTURE` static `@Bean` 으로 등록 — 일반 싱글톤보다 먼저 인스턴스화되어
|
||||
애플리케이션 빈을 조기 초기화로 끌어들이지 않는다.
|
||||
|
||||
### RequiresPermissionAuthorizationManager
|
||||
- `RequiresPermission` 의 Spring-aware 강제 메커니즘(커스텀 `AuthorizationManager<MethodInvocation>`).
|
||||
- 가로챈 메서드(또는 선언 타입)에서 애너테이션을 읽고, 현재 `Authentication` 을 프레임워크 독립적
|
||||
`AuthorizationPrincipal` 로 매핑해 결정을 application `AuthorizationPort` 에 위임한다. 따라서
|
||||
application/domain 은 어떤 Spring Security 타입도 갖지 않으며, **이 어댑터가 두 세계가 만나는 유일한 지점**이다.
|
||||
- 포트가 거부 시 application `AuthorizationDeniedException` 을 던지고, 이 매니저가 그것을 거부된
|
||||
`AuthorizationDecision` 으로 변환한다. method-security 인터셉터가 이를 `AccessDeniedException` →
|
||||
`AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 만든다(§4). `null` 반환은 기권(abstain)이라 애너테이션 없는
|
||||
메서드는 영향받지 않는다.
|
||||
- 매핑은 **fail-closed**: 미인증 요청이거나 우리 `AuthenticatedPrincipal` 이 아닌 principal 은 0개 role 로
|
||||
해석되어 거부된다.
|
||||
|
||||
### AuthorizationAdapter
|
||||
- application `AuthorizationPort` 의 web-adapter 구현체.
|
||||
- 결정은 **fail-closed**: principal 의 유효 권한 집합에 요구 권한이 없으면 `AuthorizationDeniedException` 으로
|
||||
거부 → `RequiresPermissionAuthorizationManager` 가 변환 → 최종 `AUTHZ_INSUFFICIENT_PERMISSION` 403.
|
||||
|
||||
### RolePermissionRegistry
|
||||
- 호출자의 raw role 들을 유효 `Permission` 집합으로 해석한다.
|
||||
- role 키를 **소문자로 normalize**: Keycloak 이 role 대소문자를 보장하지 않으므로 조회를 대소문자 무관으로.
|
||||
- 권한은 role 별로 **명시적으로 열거**한 집합이며 와일드카드(예: `worklog:*`)는 의도적으로 미지원 — 미래의
|
||||
`worklog:delete` 가 암묵적으로 부여되지 않도록(least-privilege, OWASP-AUTHZ-C4; §3 default B).
|
||||
- 해석은 fail-closed: 알 수 없는 role / 빈 role 집합 / 빈 registry 모두 0개 권한.
|
||||
|
||||
### RolePermissionPolicy
|
||||
- app-side role→permission 매핑 소스.
|
||||
- 키가 raw IdP role 이름인 이유: `ROLE_` 접두사는 Spring `GrantedAuthority` 에만 있고 principal 의 raw role
|
||||
집합엔 없으므로 붙이지 않는다.
|
||||
- startup-bound static config 라 staleness 가 없다.
|
||||
- **app-side config 를 기본값으로 택한 근거**: resource server 를 IdP 의 permission-claim 발급으로부터
|
||||
디커플링한다. IdP-authoritative 소스(Keycloak Authorization Services / permission claims)는 본 contract 에서
|
||||
의도적으로 out-of-scope 인 대안이다.
|
||||
|
||||
---
|
||||
|
||||
## error — 에러 → Envelope 변환
|
||||
|
||||
### GlobalExceptionHandler
|
||||
스켈레톤 공통 기반 에러 → `Envelope` 변환기.
|
||||
|
||||
- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다.
|
||||
- **운영/전송/보안 예외만** 처리한다. 도메인 예외는 소비 모듈의 별도 `@RestControllerAdvice` 가 처리하고
|
||||
Spring 이 두 advice 를 합성(compose)한다(CLAUDE.md 의 `@Order(HIGHEST_PRECEDENCE)` 규칙 참조).
|
||||
- **클라이언트 메시지는 allowlist다.** 예외 메시지, validation interpolated message, rejected
|
||||
request value, raw request URL은
|
||||
`error.message`/`details`에 넣지 않는다. `ClientSafeErrorMessages`의 코드별 고정 문구와
|
||||
정규화된 server-owned field, allowlisted reason code/fixed message, expectedType,
|
||||
supported-method 같은 bounded 구조 메타데이터만 공개한다. collection/map index와 key는 field
|
||||
path에서 제거한다.
|
||||
- 코드별 문구가 명시되지 않은 operational code는 category 기반 고정 문구로 fail-closed 한다. 이
|
||||
fallback은 새 코드를 실수로 진단 문자열에 연결하는 대신 transient/conflict/data-integrity 또는
|
||||
`Internal server error`만 공개한다.
|
||||
- `adapter-web` 에 위치하는 이유: 실행 앱이 어떤 sample 모듈에도 의존하지 않고 envelope 형식 에러 응답을
|
||||
제공하도록.
|
||||
- **`spanErrorRecorder`.** 프로덕션 코드를 특정 트레이서
|
||||
라이브러리에 결합하지 않고 span 에러를 기록하기 위한 이음새. 기본값 `SpanErrorRecorder.NOOP`. `@Autowired`
|
||||
생성자가 `ObjectProvider` 로 self-default 하므로 전체 컨텍스트 / `@WebMvcTest` 슬라이스 / 순수 단위 테스트
|
||||
모두 seam 빈 등록을 강제하지 않고 와이어링된다. Micrometer Tracing fork 는 자체 `SpanErrorRecorder` 빈만
|
||||
등록하면 no-op 을 오버라이드한다.
|
||||
|
||||
**예외 → 에러코드 → HTTP 상태 매핑 계약** (매핑 자체는 코드가 SSOT; 아래는 근거):
|
||||
|
||||
| 예외 | 코드 | 상태 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `MappingException` | `MAPPING_FAILED` | 400 | B3: 매퍼 내부 실패는 `MAPPING_FAILED` 로, `BAD_PARAMETER`/`INTERNAL_ERROR` 로 보내지 않음 |
|
||||
| `AdapterDisabledException` | `ADAPTER_DISABLED` | 500 (retryable=false) | Layer 3 런타임 fail-fast(integration-adapter-templates §4/D4). 시작-수명주기용 `REQUIRED_ADAPTER_DISABLED` 가 아님(§Audit A2). 예외 메시지의 어댑터 이름은 서버 로그용, 클라이언트는 `client_safe_message` 만 |
|
||||
| `IllegalArgumentException` | `BAD_PARAMETER` | 400 | B3: 매퍼가 아닌 호출자의 일반 예외 |
|
||||
| `ConstraintViolationException` | `VALIDATION_FAILED` | 400 | 정규화된 field + allowlisted reason code/fixed message 리스트를 details 로. interpolated message와 iterable key/index는 미노출 |
|
||||
| `MethodArgumentTypeMismatchException` | `BAD_PARAMETER` | 400 | expectedType 을 details 로 |
|
||||
| `InvalidBearerTokenException` | `INVALID_TOKEN` | 코드 상태 | |
|
||||
| `AuthenticationException` | `UNAUTHENTICATED` | 코드 상태 | |
|
||||
| `AccessDeniedException` | `SecurityErrorClassifier` 결정(예: `AUTHZ_INSUFFICIENT_PERMISSION`) | 분류기 결정 | 메서드-시큐리티 거부가 컨트롤러를 빠져나오면 여기 도달. 필터 계층 `EnvelopeAccessDeniedHandler` 와 **동일한 세분화 코드**를 내도록 무상태 classifier 에 위임 |
|
||||
| `PreconditionFailedException` | `PRECONDITION_FAILED` | 412 | D15: `If-Match` 불일치 쓰기 = 낙관적 동시성 충돌 → 412 (raw 409/500 금지) |
|
||||
| `PageValidationException` | `VALIDATION_FAILED` | 400 | D18/D20/D21: 계약 범위 밖 페이지/정렬/필터 파라미터. field + reasonCode 를 details 로 |
|
||||
| `CursorException` | `VALIDATION_FAILED` | 400 | D22: 변조/만료/손상 커서. 조언 "첫 페이지 재요청", details field="cursor" code="CURSOR_INVALID" |
|
||||
| `IdempotencyInFlightException` | `IDEMPOTENT_IN_FLIGHT` | 409 (retryable=false) | 대기 후에도 원본 처리 중. 진단정보(scope/principal)는 클라이언트 미도달 |
|
||||
| `IdempotencyRequestMismatchException` | `IDEMPOTENT_REQUEST_MISMATCH` | 422 | D8: `Idempotency-Key` 를 다른 본문으로 재사용. fingerprint/scope 노출 금지 |
|
||||
| `IdempotencyScopeMissingException` | `VALIDATION_FAILED` | 400 | §실패모드: 해석 가능한 scope 없는 키(예: 미인증 호출자)는 전역 충돌 대신 400 거부 |
|
||||
| `PersistenceFailureException` | `ex.errorCode()` (사전분류 `DB_*`) | 코드 결정 | adapter-persistence translator 가 SQLState→`DB_*` 로 이미 분류. 클라이언트 메시지는 category-derived 안전 문자열, **절대 `ex.getMessage()` 아님**(SQLState/제약명 담음, 서버 로그 전용) |
|
||||
| `DependencyFailureException` | `ex.errorCode()` (사전분류 `DEPENDENCY_*`) | 코드 결정 | adapter-outbound `OutboundHttpErrorMapper` 가 upstream 실패를 분류. 클라이언트 메시지는 per-code 고정 문자열(error-codes.yaml), **절대 `ex.getMessage()` 아님**. retryable + `retry_after_seconds` 있으면 `RetryAfterAdvisor` 로 `Retry-After` 부착 |
|
||||
| `HttpRequestMethodNotSupportedException` | `METHOD_NOT_ALLOWED` | 405 | D12: 405 는 지원 메서드를 나열한 `Allow` 헤더 필수 |
|
||||
| `HttpMediaTypeNotSupportedException` | `UNSUPPORTED_MEDIA_TYPE` | 415 | D9: 요청 본문 형식 미지원 — 406 과 구별 |
|
||||
| `MaxUploadSizeExceededException` | `PAYLOAD_TOO_LARGE` | 413 | D8: 과대 본문은 envelope 내 413, raw 500 금지. 멀티파트 전용 413(`UPLOAD_SIZE_EXCEEDED`)은 이 영역의 책임 — 병합 후 정제 |
|
||||
| `HttpMediaTypeNotAcceptableException` | `NOT_ACCEPTABLE` | 406 | D9: Accept 에 맞는 표현 없음 — 415 와 구별(합치면 RFC 9110 의미론 상실) |
|
||||
| `MethodArgumentNotValidException` | `VALIDATION_FAILED` | 코드 상태 | 정규화된 field + allowlisted reason code/fixed message 리스트를 details 로. rejectedValue/defaultMessage/iterable key/index는 secret/PII 가능성이 있어 미노출 |
|
||||
| `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 |
|
||||
| `NoHandlerFoundException`, `NoResourceFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | controller/static-resource 어느 404 경로도 같은 Envelope를 사용하고 raw request URL을 echo하지 않음 |
|
||||
| `Exception` (catch-all) | `INTERNAL_ERROR` | 500 | span 에러 기록 + "Internal server error" 고정 메시지 |
|
||||
|
||||
### ErrorResponseFactory
|
||||
- 기반 운영 핸들러와 모든 도메인 핸들러가 **공유**하여 envelope 형식이 정확히 한 곳에서만 만들어지게
|
||||
하는 단일-소스 컴포넌트(`httpStatus()` → Spring `HttpStatus` 매핑, `error.category` 운반, MDC 에서 `meta` 추출).
|
||||
|
||||
---
|
||||
|
||||
## envelope / filter
|
||||
|
||||
### EnvelopeBodyAdvice
|
||||
- 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상
|
||||
`{success, data | error, traceId}` 로 보장한다.
|
||||
- 위치: sample 모듈이 아니라 adapter-web. 실행 앱은 adapter-web 에 의존하지만 sample-portfolio 에는 의존하지
|
||||
않으므로, 응답 래핑이 실제로 동작하려면 여기 있어야 한다.
|
||||
|
||||
### CacheControlFilter
|
||||
- 스켈레톤 기본 HTTP 캐시 정책.
|
||||
- `Cache-Control: no-store` 는 인증된 API 의 안전한 기본값. `Vary: Accept, Accept-Encoding, Authorization` 로
|
||||
공유 프록시/CDN 이 협상이나 주체를 가로질러 콘텐츠를 오염(poison)시키지 못하게 한다.
|
||||
- 기본값을 체인 **이전**에 설정: 캐시 가능한 엔드포인트가 반환값 처리에서 `Cache-Control`(예:
|
||||
`private, max-age=60`)을 가진 `ResponseEntity` 를 반환해 기본값을 덮어쓰는 opt-in 이 가능하도록.
|
||||
- 책임 경계: 이 모듈은 캐시 *헤더 정책*을 소유하고, 캐시 *레이어*(Redis/CDN)는 별도 인프라가 소유한다.
|
||||
- 단일 소유권: Spring Security 기본 `Cache-Control` 은 `SecurityConfig` 에서 비활성화 → 실행 앱에서 이 필터가
|
||||
헤더 단일 소유자. 독립 MockMvc(보안 체인 없음)에서도 이 필터가 유일 writer.
|
||||
|
||||
### RequestLoggingFilter
|
||||
- **MDC 키 정책(D11/D19).** `MdcKeys` 의 snake_case 키 사용.
|
||||
- **인바운드 id 헤더(D14/D15).** `X-Request-Id` / `X-Correlation-Id` 는 사용 전 sanitize(CR/LF + control 제거)
|
||||
및 길이 제한. 부재/공백은 서버 생성.
|
||||
- **W3C `traceparent`(D5/D7/D4).** 유효한 인바운드 traceparent 가 있으면 채택해 그 `traceId`→MDC `trace_id`,
|
||||
`spanId`→`span_id`. 부재/공백/무효면 fresh ROOT traceparent 생성(32-hex traceId, 16-hex spanId,
|
||||
sampled=false)하여 MDC `trace_id` 가 **항상** 의미 있는 W3C id 이고 절대 null 이 아니게 한다(D4: 추적 비활성
|
||||
상태에서도 `meta.traceId` non-null 보장). 해석된 traceparent 는 응답 헤더에 설정.
|
||||
- `sampled=false` 근거: tracer seam 이 실제 sampling 결정을 소유하며 스켈레톤엔 exporter 가 없다.
|
||||
- `freshHex16` 근거: 16-char span id 는 fresh UUID 의 least-significant bits 에서 파생·zero-pad — 64비트
|
||||
전체가 entropy 를 갖도록(UUIDv4 version nibble 은 most-significant bits 라 제외). variant bits 가 값을
|
||||
non-zero 로 유지해 W3C non-all-zero 규칙 충족.
|
||||
- **사용자 주체 가명화.** `user_principal` 은 MDC 에 놓이기 전
|
||||
`UserPrincipalPseudonymizerPort` 로 가명화. raw `idpUserId()` 는 절대 MDC/로그에 기록되지 않는다.
|
||||
- **route template 해석.** 저-cardinality 매칭 라우트 템플릿 반환. `BEST_MATCHING_PATTERN_ATTRIBUTE`
|
||||
는 handler mapping 이후 DispatcherServlet 이 설정하므로 `finally` 블록에서 항상 사용 가능.
|
||||
- **주의 — 생성된 `trace_id` 는 실제 span 의 trace-id 가 아니다.** 무-tracer
|
||||
스켈레톤에선 이 필터가(인바운드 traceparent 부재 시) `trace_id` 를 발급(MINT)하고 `ResponseMetaFactory` 가
|
||||
이를 `meta.traceId` 로 투영한다. fork 가 Micrometer Tracing 을 켜면 OTel SDK 도 같은 요청에 trace-id 를
|
||||
발급하고 SLF4J-Micrometer 브리지가 *자신의* id 를 MDC `trace_id` 에 쓴다. 어느 값이 최종 반영될지는
|
||||
필터/observation 의 **ORDER 와 scope** 에 달려 있다 — 이 필터가 이기면 클라이언트의 `meta.traceId` 가
|
||||
실제 export 된 span 의 trace-id 와 불일치해, "응답 id 로 trace 조회"라는 D4 의 핵심 목적이 조용히 깨진다.
|
||||
실제 tracer 를 연결하는 fork 는 tracer 가 MDC `trace_id` 의 유일 소유자가 되게 해야 한다(이 필터를 tracing
|
||||
observation *이후*로 정렬하거나, 생성 대신 `Span.current()` 채택). 현 green 테스트 스위트는 이를 잡지
|
||||
못한다 — 무-tracer 메커니즘만 검증한다.
|
||||
|
||||
---
|
||||
|
||||
## ratelimit
|
||||
|
||||
### provider-neutral edge contract
|
||||
|
||||
- inbound web은 `shared-contract`의 `EdgeRateLimitPort`만 호출한다. Redis key, Lua, local counter와
|
||||
provider 설정을 알지 못한다.
|
||||
- outbound provider activation SSOT는
|
||||
`ca-skeleton.capabilities.rate-limit.provider=disabled|redis`이고, HTTP enforcement의 별도 축은
|
||||
`app.rate-limit.enabled`다. transport가 enabled인데 exact provider가 없거나 중복이면 startup을
|
||||
실패시킨다.
|
||||
- fixed window, sliding counter, token bucket 선택과 policy revision은 Redis provider가 소유한다.
|
||||
과거 process-local unbounded fixed-window map/factory/settings는 제거되었다. local emergency가
|
||||
필요하면 bounded cardinality/TTL/in-flight와 명시적 degraded-provider 계약을 먼저 추가해야 하며,
|
||||
silent primary fallback은 허용하지 않는다.
|
||||
- `EdgeRateLimitTransportBridge`는 provider의 typed allow/deny/unavailable/incompatible outcome을
|
||||
HTTP 2xx/429/503과 `Retry-After`로만 투영한다. timeout은 quota가 소비되지 않았다는 증거가 아니다.
|
||||
|
||||
### RateLimitKeyResolver
|
||||
- 키 형태: service-to-service
|
||||
`apikey:<id>`(api_key_id override), 인증 `user:<id>`, 미인증 `ip:<source-ip>:<METHOD route-template>`(정규화).
|
||||
- principal 은 로그 `user_principal`(`AuthenticatedPrincipal#idpUserId`)과 동일 표현 재사용. pseudonymization 은
|
||||
이 영역의 책임 seam — 이 브랜치는 표현을 재사용만 하고 변환하지 않는다.
|
||||
- tenant prefixing 은 아직 구현하지 않은 확장 지점이다.
|
||||
- **하드 룰:** 키는 raw 토큰이나 요청 본문에서 절대 도출하지 않는다.
|
||||
- `HandlerInterceptor` 입력으로 resolve 하는 이유: route template(`/v1/worklogs/{id}`)을 쓰기 위함. servlet
|
||||
filter 는 handler mapping 이전에 실행돼 구체 경로만 보므로 모든 id 가 서로 다른 키가 되어버린다.
|
||||
|
||||
### RateLimitClientIpMode
|
||||
- 비인증 rate-limit 키의 클라이언트 IP 소스 선택 enum.
|
||||
- `REMOTE_ADDR_ONLY` — 직접 노출 배포의 안전한 기본값(spoofing 가능한 forwarded 헤더 무시).
|
||||
- `FORWARDED_HEADERS_TRUSTED` — 신뢰할 수 있는 ingress/LB 가 forwarded 헤더를 덮어쓰는 경우에만 사용.
|
||||
|
||||
### RateLimitWebConfig
|
||||
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
|
||||
resolve 되기 때문(RateLimitKeyResolver 참조).
|
||||
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
|
||||
슬라이스에서도 `EdgeRateLimitTransportSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
|
||||
슬라이스에선 `Clock#systemUTC()` 로 fallback.
|
||||
|
||||
### RateLimitInterceptor
|
||||
- provider가 선택한 rate-limit policy를 매핑된 handler 실행 전에 적용. quota 결과에는
|
||||
`X-RateLimit-*` 헤더를 포함한다(generated_if_missing=true).
|
||||
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
|
||||
의존성 장애로 오분류하는 것을 막는다.
|
||||
|
||||
---
|
||||
|
||||
## settings / config / http
|
||||
|
||||
### CorsSettings
|
||||
- **3계층 검증 전략.**
|
||||
1. 단순 제약(범위/필수/정규식)은 JSR-303 + `@Validated` 로 선언해 잘못된 값이 `BindValidationException` 으로
|
||||
기동 실패(`maxAgeSeconds`).
|
||||
2. JSR-303 로 표현 불가한 조건부/교차필드 규칙은 compact constructor 의 fail-fast `throw` 로 강제(관대한 기본값
|
||||
폴백 금지).
|
||||
3. 정상 기본값(CORS disabled 시 빈 origins, 미설정 method/header)은 invalid 가 아니라 합리적 기본값으로 채움.
|
||||
- **교차필드 불변식**: CORS enabled 시 최소 하나의 allowed origin 필수(JSR-303 표현 불가 → fail-fast). 빈 목록
|
||||
관대한 폴백은 모든 브라우저 호출자를 조용히 거부하게 된다.
|
||||
- **D9 (WHATWG Fetch §3.3, FETCH-CORS-C3)**: wildcard origin + credentials 금지 — `Access-Control-Allow-Origin: *`
|
||||
는 `Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
|
||||
시점에 fail-fast 거부.
|
||||
|
||||
### EdgeRateLimitTransportSettings
|
||||
|
||||
- `app.rate-limit.*`은 HTTP enforcement, default policy ID, pseudonymization key version,
|
||||
caller deadline, trusted client-IP mode만 소유한다.
|
||||
- algorithm/quota/state TTL/HMAC secret는 outbound Redis capability 설정이 소유하며 web settings로
|
||||
복제하지 않는다.
|
||||
|
||||
### SecuritySettings
|
||||
- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가
|
||||
기동 시 실패하므로 여기서 명확한 에러를 먼저 표면화한다. 나머지 knob 은 warn 후 폴백.
|
||||
|
||||
### PresentationSettings
|
||||
- 검증 정책 "warn-and-default": 부재/잘못된 prefix 값은 앱을 멈추는 대신 빈 prefix 로 폴백 — 모든 엔드포인트가
|
||||
(/api 없이) 계속 접근 가능하게 유지.
|
||||
|
||||
### JacksonNullableConfig
|
||||
- `JsonNullableModule` 을 Spring 관리 `ObjectMapper` 에 등록. 없으면 PATCH 요청 DTO 의 `JsonNullable<T>`(B2)를
|
||||
Jackson 이 역직렬화하지 못해 absent / explicit-null 구분이 조용히 붕괴된다.
|
||||
- 스켈레톤 전역 web 관심사(공유 `Patch<T>` 타입과 짝)라 도메인 샘플 모듈이 아니라 adapter-web 에 위치.
|
||||
|
||||
### ApiHeaders
|
||||
- 인바운드 web 어댑터 전역의 HTTP 헤더명 상수 — `docs/registries/headers.yaml`의
|
||||
코드 미러. 리터럴을 중앙집중해 controller/filter/advice 가 casing 으로 drift 하지 않게 하고, 레지스트리
|
||||
일관성 테스트가 단일 출처를 참조하게 한다.
|
||||
- 소유권: `X-Api-Version`(D2)·`Idempotency-Key`(D3 — 이름만; key shape/scope/replay 정책은
|
||||
application-core 소유)는 여기서 생산. conditional-request(D15)·cache(D16)·method/negotiation/
|
||||
LRO(D12/D17)·pagination(D18)·rate-limit signaling(generated_if_missing=true; Limit/Remaining 은 numeric, Reset 은
|
||||
rfc3339 = fixed-window end)·deep-offset deprecation marker(D18)·always-emitted(D24)는 표준 RFC 9110/9111 이름 참조.
|
||||
|
||||
---
|
||||
|
||||
## observability
|
||||
|
||||
### MdcKeys
|
||||
- snake_case MDC 키 이름은 로그/진단 레지스트리(`mdc-keys.yaml`)를 따른다.
|
||||
같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며,
|
||||
변환 단일 지점은 `ResponseMetaFactory`.
|
||||
|
||||
### MdcCorrelationIdPortAdapter
|
||||
- `RequestLoggingFilter`가 무해화하고 MDC `correlation_id`에 넣은 값을 application-core의
|
||||
`CorrelationIdPort`로 투영한다.
|
||||
- absent/blank는 `Optional.empty()`로 반환한다. application/sample 계층은 SLF4J/MDC를 직접
|
||||
참조하지 않고 event-id fallback 정책만 소유한다.
|
||||
|
||||
### HeaderSanitizer
|
||||
- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을
|
||||
가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를
|
||||
제거 후 길이 제한.
|
||||
- `프로젝트 선택`: 구체 문자셋 정책(strip vs encode)과 최대 길이는 ca-tmpl 트레이드오프. OWASP 는
|
||||
원칙만 규정하고 정규식/한계는 규정하지 않는다.
|
||||
|
||||
### ResponseMetaFactory
|
||||
- snake_case MDC 진단 키를 camelCase `ResponseMeta` wire 객체로 projection 하는 D19 단일 변환 지점. adapter-web
|
||||
에 위치하는 이유: shared-contract 는 프레임워크 중립이라 MDC 를 읽으면 안 된다.
|
||||
|
||||
### RetryAfterAdvisor
|
||||
- **`Retry-After` 노출 지점.** 구체 헤더 값과 429/503 세부는 이 영역의 책임이고,
|
||||
per-code `retry_after_seconds` 는 error-codes.yaml 에 존재한다.
|
||||
이 helper 는 "재시도 가능한 코드가 `Retry-After` 헤더를 받을 자격이 있는가?"만 답해, 호출부가
|
||||
재시도 가능 여부를 재도출하지 않고 헤더를 붙이게 한다.
|
||||
- **Tracing wiring:** 운영 5xx 는 서버 span 에 `exception` 이벤트 + span status ERROR 를 기록해야 하나,
|
||||
Micrometer-Tracing/OTel 가 classpath 에 없어 wiring 은 이 영역의 책임 — 의도적
|
||||
미구현.
|
||||
- 필드 `RETRY_AFTER_SECONDS` 는 error-codes.yaml 의 `retry_after_seconds` 컬럼 미러.
|
||||
이 advisor 가 유일한 Retry-After 노출 지점이라 여기 중앙화한다.
|
||||
`DEPENDENCY_4XX_CLIENT` 는 비재시도(retryable=false)라 `shouldAdvise` 가드로 empty 반환.
|
||||
|
||||
---
|
||||
|
||||
## pagination
|
||||
|
||||
### PageParams
|
||||
- 검증된 offset 페이지네이션 파라미터. `page` 0-indexed: Spring `Pageable`
|
||||
parity(SPRING-PAGE-C1). `size` 기본 20 / min 1 / max 100: 프로젝트 DoS 캡(Spring 자체 `DEFAULT_MAX_PAGE_SIZE`
|
||||
는 2000, SPRING-PAGE-C4).
|
||||
- `프로젝트 선택`: 정확한 size 캡(100)/min(1)/deep-offset 임계값(10000)은 프로젝트 내부
|
||||
트레이드오프 — 표준은 원칙만 고정하고 숫자는 고정하지 않는다.
|
||||
|
||||
### SortParam
|
||||
- Spring `Pageable` 네이티브 문법 `field,direction` 의 단일 정렬 term(D20). 비-네이티브 문법 거부 근거:
|
||||
JSON:API prefix(`-foo`)·colon form(`foo:desc`)·AIP-132 space form(`"foo desc"`)은 모두 Spring 자동 바인딩을
|
||||
깨뜨리므로 금지.
|
||||
|
||||
### PageValidationException
|
||||
- 페이지네이션/정렬 요청 파라미터가 스켈레톤의 요청 경계를 위반할 때 발생.
|
||||
|
||||
---
|
||||
|
||||
## cursor
|
||||
|
||||
### CursorCodec
|
||||
- 불투명·서명·시간 제한 페이지네이션 커서 코덱(D22, AIP158-C5).
|
||||
- **SEAM(producer-only)**: HMAC 키와 회전 정책은 이 영역의 책임. 해당 브랜치가 이
|
||||
저장소에 없어 프로덕션 키 wiring 은 `planned`. 코덱은 주입된 키를 받고 테스트/로컬용 `withDevKey()` 팩토리
|
||||
제공(프로덕션 금지). encode/decode 메커니즘·opacity·무결성 검사·TTL 은 여기 구현.
|
||||
- `DEFAULT_TTL`: D22 의 24h TTL 은 프로젝트 내부 숫자(AIP-158 은 opacity 만 고정, TTL 미고정).
|
||||
|
||||
### CursorException
|
||||
- 불투명 페이지네이션 커서 검증 실패 시 발생(D22).
|
||||
|
||||
---
|
||||
|
||||
## conditional
|
||||
|
||||
### ETags
|
||||
- HTTP 계층 낙관적 동시성/캐시 검증용 weak-ETag 도출 및 조건부 요청 매칭(D15, RFC9110-C13..C17).
|
||||
`weakFromVersion` 산출물 `W/"<version>"` 는 스켈레톤의 예시 wire 형태다.
|
||||
- `프로젝트 선택`: RFC 9110 은 `If-Match` 에 strong 비교를 의무화하나, 이 스켈레톤은 불투명 값을
|
||||
leniently 비교(`W/` weak 마커와 둘러싼 따옴표 무시)해 문서화된 weak-ETag 형태로도 낙관적 잠금을 구동한다.
|
||||
strong ETag 를 발행하는 프로덕션 fork 도 동일 호출 지점을 유지 가능.
|
||||
|
||||
### PreconditionFailedException
|
||||
- 쓰기 요청의 `If-Match` validator 가 현재 리소스 ETag 와 불일치할 때 발생(D15). 412 로 매핑해 raw 409/500 과
|
||||
구분 — persistence 계층이 serialization failure 로 surface 할 동일한 낙관적 동시성 충돌의 HTTP 계층 표현.
|
||||
|
||||
---
|
||||
|
||||
## idempotency
|
||||
|
||||
### IdempotencyKeySupport
|
||||
- HTTP 요청으로부터 application `IdempotencyExecutor` 입력을 조립하는 web 측 helper.
|
||||
- principal 은 인증된 `AuthenticatedPrincipal#idpUserId()` — rate-limit 키 및 로그 `user_principal` 과 동일
|
||||
표현. 미인증 호출자는 principal 이 없어 `IdempotencyScope.of`
|
||||
가 `IdempotencyScopeMissingException`(→ 400)으로 거부 → scope 없는 키의 전역 충돌 방지.
|
||||
- `프로젝트 선택`: fingerprint 는 raw 전송 바이트가 아니라 직렬화된 command payload 기준으로
|
||||
계산 → JSON 키 순서/공백 차이로 인한 false mismatch 방지. 단, 바이트 동일 body 를 두 번 POST 한 클라이언트는
|
||||
여전히 매칭. 완전한 요청 canonicalization 은 실제 요청 패턴으로 추가 검증이 필요하다.
|
||||
- tenant 는 null(단일 테넌트); tenant scoping 은 아직 구현하지 않은 확장 지점이다.
|
||||
|
||||
### JsonIdempotentResponseCodec
|
||||
- Jackson 기반 `IdempotentResponseCodec`(§B): web 어댑터가 application executor 의 저장/replay JSON wire 포맷을
|
||||
소유. (역)직렬화 실패는 `MappingException` 으로 surface 되어 base handler 가 raw 500 이 아닌 `MAPPING_FAILED`
|
||||
400 으로 라우팅.
|
||||
@@ -0,0 +1,74 @@
|
||||
// HTTP / web adapters. Depends on application and shared operational contracts.
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.session:spring-session-core'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
implementation('org.openapitools:jackson-databind-nullable:0.2.6') {
|
||||
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
|
||||
}
|
||||
// feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the
|
||||
// running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated —
|
||||
// never a hand-maintained stale schema). The release-blocking drift gate is
|
||||
// owned by feature-contract-verification-test-suite (planned).
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
||||
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
|
||||
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
|
||||
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
|
||||
// WebApplicationType deduction keeps resolving SERVLET; the reactive handlers are wired only
|
||||
// when the fileserver reactive profile is selected.
|
||||
implementation 'org.springframework:spring-webflux'
|
||||
implementation 'io.projectreactor:reactor-core'
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
}
|
||||
|
||||
tasks.register('jpaPersistenceRedactionContractTest', Test) {
|
||||
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'
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
shouldRunAfter tasks.named('test')
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
afterSuite { descriptor, result ->
|
||||
if (descriptor.parent == null && result.skippedTestCount > 0) {
|
||||
throw new GradleException(
|
||||
"webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('webSecurityBoundaryTest')
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Writes a classified security failure to the servlet response as the skeleton-wide {@link
|
||||
* Envelope} (same shape as every other error), and logs it safely. The response body and log line
|
||||
* carry only redacted, client-safe metadata. See README for the design rationale.
|
||||
*/
|
||||
class AuthErrorResponseWriter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthErrorResponseWriter.class);
|
||||
|
||||
/** Client-safe messages, aligned with the registry {@code client_safe_message} column. */
|
||||
private static final Map<OperationalError, String> CLIENT_MESSAGES =
|
||||
Map.of(
|
||||
OperationalError.AUTH_TOKEN_MISSING, "Authentication required",
|
||||
OperationalError.AUTH_TOKEN_EXPIRED, "Authentication expired",
|
||||
OperationalError.AUTH_KID_UNKNOWN, "Authentication failed, please retry",
|
||||
OperationalError.AUTH_JWKS_UNAVAILABLE, "Authentication service temporarily unavailable",
|
||||
OperationalError.AUTHZ_INSUFFICIENT_PERMISSION, "Permission denied",
|
||||
OperationalError.AUTHZ_TENANT_MISMATCH, "Permission denied");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
AuthErrorResponseWriter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
void write(HttpServletRequest request, HttpServletResponse response, OperationalError code)
|
||||
throws IOException {
|
||||
// Log only safe metadata — never the token or the raw failure message.
|
||||
log.warn(
|
||||
"security failure: code={} category={} method={} path={}",
|
||||
code.code(),
|
||||
code.category(),
|
||||
request.getMethod(),
|
||||
request.getRequestURI());
|
||||
|
||||
response.setStatus(code.httpStatus());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
|
||||
// A 401 response carries a minimal WWW-Authenticate header (no issuer / token detail).
|
||||
if (code.httpStatus() == 401) {
|
||||
response.setHeader(
|
||||
HttpHeaders.WWW_AUTHENTICATE,
|
||||
code == OperationalError.AUTH_TOKEN_MISSING
|
||||
? "Bearer"
|
||||
: "Bearer error=\"invalid_token\"");
|
||||
}
|
||||
RetryAfterAdvisor.retryAfterSeconds(code)
|
||||
.ifPresent(
|
||||
seconds -> response.setHeader(HttpHeaders.RETRY_AFTER, Integer.toString(seconds)));
|
||||
|
||||
Envelope<Void> body = ErrorResponseFactory.body(code, clientMessage(code), null);
|
||||
objectMapper.writeValue(response.getWriter(), body);
|
||||
}
|
||||
|
||||
private String clientMessage(OperationalError code) {
|
||||
return CLIENT_MESSAGES.getOrDefault(code, "Authentication failed");
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Principal exposed to controllers via {@code @AuthenticationPrincipal}. Carries the IdP-side
|
||||
* identifier ({@code idpUserId}, JWT {@code sub} claim) plus any claims a controller is likely to
|
||||
* want without reaching into the raw Jwt.
|
||||
*/
|
||||
public record AuthenticatedPrincipal(String idpUserId, String email, Set<String> roles) {
|
||||
|
||||
public AuthenticatedPrincipal {
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
}
|
||||
|
||||
public boolean hasRole(String role) {
|
||||
return roles.contains(role);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Resource-server {@link AccessDeniedHandler} that maps an authorization failure (valid token,
|
||||
* insufficient permission) to {@code AUTHZ_INSUFFICIENT_PERMISSION} (403) and writes it as the
|
||||
* skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class EnvelopeAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private final SecurityErrorClassifier classifier;
|
||||
private final AuthErrorResponseWriter writer;
|
||||
|
||||
public EnvelopeAccessDeniedHandler(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
this.classifier = classifier;
|
||||
this.writer = new AuthErrorResponseWriter(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException)
|
||||
throws IOException {
|
||||
writer.write(request, response, classifier.classifyAccessDenied(accessDeniedException));
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Resource-server {@link AuthenticationEntryPoint} that classifies an authentication failure into a
|
||||
* fine-grained {@link dev.caskeleton.shared.error.OperationalError} and writes it as the
|
||||
* skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class EnvelopeAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final SecurityErrorClassifier classifier;
|
||||
private final AuthErrorResponseWriter writer;
|
||||
|
||||
public EnvelopeAuthenticationEntryPoint(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
this.classifier = classifier;
|
||||
this.writer = new AuthErrorResponseWriter(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException)
|
||||
throws IOException {
|
||||
writer.write(request, response, classifier.classifyAuthentication(authException));
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoderInitializationException;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
|
||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.SupplierJwtDecoder;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
/**
|
||||
* Custom {@link JwtDecoder} for the resource server with an explicit validator chain: timestamp
|
||||
* (60s clock skew) + issuer, plus an optional audience check when configured. JWKS discovery is
|
||||
* deferred via {@link SupplierJwtDecoder} so startup does not require the IdP to be reachable. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.security.auth-mode",
|
||||
havingValue = "jwt",
|
||||
matchIfMissing = true)
|
||||
public class JwtDecoderConfig {
|
||||
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder(SecuritySettings settings) {
|
||||
// Lazy: JWKS discovery happens on first decode, not at startup.
|
||||
SupplierJwtDecoder lazyDecoder =
|
||||
new SupplierJwtDecoder(
|
||||
() -> {
|
||||
NimbusJwtDecoder decoder =
|
||||
NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build();
|
||||
decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience()));
|
||||
return decoder;
|
||||
});
|
||||
return token -> {
|
||||
try {
|
||||
return lazyDecoder.decode(token);
|
||||
} catch (JwtDecoderInitializationException exception) {
|
||||
if (causedByExternalKeyService(exception)) {
|
||||
throw new AuthenticationKeyServiceUnavailableException(exception);
|
||||
}
|
||||
throw new AuthenticationDecoderMisconfigurationException(exception);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean causedByExternalKeyService(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
for (int depth = 0; current != null && depth < 32; depth++) {
|
||||
if (current instanceof RestClientException || current instanceof IOException) {
|
||||
return true;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The explicit validator chain: timestamp (60s skew) + issuer + optional audience. */
|
||||
static OAuth2TokenValidator<Jwt> jwtValidator(String issuerUri, String audience) {
|
||||
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
|
||||
validators.add(new JwtTimestampValidator(Duration.ofSeconds(60)));
|
||||
validators.add(new JwtIssuerValidator(issuerUri));
|
||||
if (audience != null && !audience.isBlank()) {
|
||||
validators.add(audienceValidator(audience));
|
||||
}
|
||||
return new DelegatingOAuth2TokenValidator<>(validators);
|
||||
}
|
||||
|
||||
private static OAuth2TokenValidator<Jwt> audienceValidator(String audience) {
|
||||
return jwt -> {
|
||||
if (jwt.getAudience() != null && jwt.getAudience().contains(audience)) {
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
OAuth2Error error = new OAuth2Error("invalid_token", "The aud claim is not valid", null);
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
};
|
||||
}
|
||||
|
||||
static final class AuthenticationKeyServiceUnavailableException extends JwtException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
AuthenticationKeyServiceUnavailableException(Throwable cause) {
|
||||
super("Authentication key service unavailable", cause);
|
||||
}
|
||||
}
|
||||
|
||||
static final class AuthenticationDecoderMisconfigurationException extends JwtException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
AuthenticationDecoderMisconfigurationException(Throwable cause) {
|
||||
super("Authentication decoder configuration is invalid", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Maps an OIDC JWT to a JwtAuthenticationToken whose principal is our {@link
|
||||
* AuthenticatedPrincipal}. We pull {@code sub} as the IdP user id and union Keycloak-style {@code
|
||||
* realm_access.roles} with {@code resource_access[*].roles} into a single role set. Roles also
|
||||
* become Spring authorities (ROLE_*).
|
||||
*/
|
||||
@Component
|
||||
public class JwtToAuthenticatedPrincipalConverter
|
||||
implements Converter<Jwt, AbstractAuthenticationToken> {
|
||||
|
||||
@Override
|
||||
public AbstractAuthenticationToken convert(Jwt jwt) {
|
||||
Set<String> roles = extractRoles(jwt);
|
||||
String email = jwt.getClaimAsString("email");
|
||||
AuthenticatedPrincipal principal = new AuthenticatedPrincipal(jwt.getSubject(), email, roles);
|
||||
Collection<GrantedAuthority> authorities =
|
||||
roles.stream()
|
||||
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT)))
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
return new AuthenticatedJwtToken(jwt, authorities, principal);
|
||||
}
|
||||
|
||||
private Set<String> extractRoles(Jwt jwt) {
|
||||
Set<String> roles = new HashSet<>();
|
||||
|
||||
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
|
||||
if (realmAccess != null) {
|
||||
Object r = realmAccess.get("roles");
|
||||
if (r instanceof Collection<?> col) {
|
||||
col.forEach(x -> roles.add(String.valueOf(x)));
|
||||
}
|
||||
}
|
||||
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
|
||||
if (resourceAccess != null) {
|
||||
for (Object client : resourceAccess.values()) {
|
||||
if (client instanceof Map<?, ?> clientMap
|
||||
&& clientMap.get("roles") instanceof Collection<?> rolesCol) {
|
||||
rolesCol.forEach(x -> roles.add(String.valueOf(x)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generic OIDC "roles" claim as a fallback
|
||||
List<String> flat = jwt.getClaimAsStringList("roles");
|
||||
if (flat != null) {
|
||||
roles.addAll(flat);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* JwtAuthenticationToken whose {@link #getPrincipal()} is our domain-oriented record instead of
|
||||
* the raw Jwt. Both are kept available — controllers usually want the record; filters/loggers can
|
||||
* still pull the Jwt via {@link #getToken()}.
|
||||
*/
|
||||
public static final class AuthenticatedJwtToken extends JwtAuthenticationToken {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
// transient: the principal is reconstructed by the converter on each authentication,
|
||||
// never round-tripped through Java serialization. See README for the design rationale.
|
||||
private final transient AuthenticatedPrincipal principal;
|
||||
|
||||
AuthenticatedJwtToken(
|
||||
Jwt jwt,
|
||||
Collection<? extends GrantedAuthority> authorities,
|
||||
AuthenticatedPrincipal principal) {
|
||||
super(jwt, authorities);
|
||||
this.principal = principal;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import jakarta.servlet.AsyncContext;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpRequestResponseHolder;
|
||||
import org.springframework.security.web.context.SaveContextOnUpdateOrErrorResponseWrapper;
|
||||
import org.springframework.security.web.context.SecurityContextRepository;
|
||||
import org.springframework.web.util.WebUtils;
|
||||
|
||||
/**
|
||||
* Stores only a bounded primitive authentication snapshot in {@link HttpSession}.
|
||||
*
|
||||
* <p>Spring Security objects, credentials, tokens and arbitrary principal graphs never cross the
|
||||
* Spring Session serialization boundary.
|
||||
*/
|
||||
final class PrimitiveSessionSecurityContextRepository implements SecurityContextRepository {
|
||||
|
||||
static final String SNAPSHOT_ATTRIBUTE = "dev.caskeleton.security.PRIMITIVE_SECURITY_CONTEXT_V1";
|
||||
|
||||
private static final int MAGIC = 0x43534543;
|
||||
private static final int VERSION = 1;
|
||||
private static final int MAXIMUM_SNAPSHOT_BYTES = 16_384;
|
||||
private static final int MAXIMUM_PRINCIPAL_BYTES = 256;
|
||||
private static final int MAXIMUM_EMAIL_BYTES = 320;
|
||||
private static final int MAXIMUM_TOKEN_BYTES = 128;
|
||||
private static final int MAXIMUM_ROLES = 64;
|
||||
private static final int MAXIMUM_AUTHORITIES = 128;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("deprecation")
|
||||
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
|
||||
HttpServletRequest request = requestResponseHolder.getRequest();
|
||||
SecurityContext context = load(request);
|
||||
HttpServletResponse response = requestResponseHolder.getResponse();
|
||||
if (response != null) {
|
||||
CommitSaveResponseWrapper wrappedResponse = new CommitSaveResponseWrapper(response, request);
|
||||
wrappedResponse.setSecurityContextHolderStrategy(
|
||||
SecurityContextHolder.getContextHolderStrategy());
|
||||
requestResponseHolder.setResponse(wrappedResponse);
|
||||
requestResponseHolder.setRequest(new AsyncAwareRequestWrapper(request, wrappedResponse));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveContext(
|
||||
SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
|
||||
CommitSaveResponseWrapper wrapper =
|
||||
WebUtils.getNativeResponse(response, CommitSaveResponseWrapper.class);
|
||||
if (wrapper != null) {
|
||||
wrapper.reconcileFinalContext(context);
|
||||
return;
|
||||
}
|
||||
saveSnapshot(context, request);
|
||||
}
|
||||
|
||||
private static void saveSnapshot(SecurityContext context, HttpServletRequest request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
Authentication authentication = context == null ? null : context.getAuthentication();
|
||||
if (authentication == null
|
||||
|| !authentication.isAuthenticated()
|
||||
|| authentication instanceof AnonymousAuthenticationToken) {
|
||||
HttpSession existing = request.getSession(false);
|
||||
if (existing != null) {
|
||||
existing.removeAttribute(SNAPSHOT_ATTRIBUTE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
request.getSession(true).setAttribute(SNAPSHOT_ATTRIBUTE, encode(authentication));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsContext(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
return session != null && session.getAttribute(SNAPSHOT_ATTRIBUTE) instanceof byte[];
|
||||
}
|
||||
|
||||
private static SecurityContext load(HttpServletRequest request) {
|
||||
SecurityContext empty = SecurityContextHolder.createEmptyContext();
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return empty;
|
||||
}
|
||||
Object stored = session.getAttribute(SNAPSHOT_ATTRIBUTE);
|
||||
if (!(stored instanceof byte[] snapshot)) {
|
||||
return empty;
|
||||
}
|
||||
try {
|
||||
PrimitiveAuthentication decoded = decode(snapshot);
|
||||
AuthenticatedPrincipal principal =
|
||||
new AuthenticatedPrincipal(decoded.principalId, decoded.email, decoded.roles);
|
||||
List<GrantedAuthority> authorities =
|
||||
decoded.authorities.stream()
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.map(GrantedAuthority.class::cast)
|
||||
.toList();
|
||||
empty.setAuthentication(
|
||||
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
|
||||
return empty;
|
||||
} catch (IllegalArgumentException exception) {
|
||||
session.removeAttribute(SNAPSHOT_ATTRIBUTE);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encode(Authentication authentication) {
|
||||
if (!(authentication.getPrincipal() instanceof AuthenticatedPrincipal principal)) {
|
||||
throw new IllegalArgumentException(
|
||||
"redis-session authentication requires an AuthenticatedPrincipal");
|
||||
}
|
||||
Set<String> roles = boundedTokens(principal.roles(), MAXIMUM_ROLES, "roles");
|
||||
Set<String> authorities =
|
||||
boundedTokens(
|
||||
authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(),
|
||||
MAXIMUM_AUTHORITIES,
|
||||
"authorities");
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (DataOutputStream output = new DataOutputStream(bytes)) {
|
||||
output.writeInt(MAGIC);
|
||||
output.writeByte(VERSION);
|
||||
writeText(output, principal.idpUserId(), MAXIMUM_PRINCIPAL_BYTES, "principal ID");
|
||||
writeNullableText(output, principal.email(), MAXIMUM_EMAIL_BYTES, "email");
|
||||
writeTokens(output, roles);
|
||||
writeTokens(output, authorities);
|
||||
}
|
||||
byte[] snapshot = bytes.toByteArray();
|
||||
if (snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
|
||||
throw new IllegalArgumentException("security context snapshot exceeds the byte bound");
|
||||
}
|
||||
return snapshot;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("in-memory security context encoding failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static PrimitiveAuthentication decode(byte[] snapshot) {
|
||||
if (snapshot.length < 1 || snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
|
||||
throw invalidSnapshot();
|
||||
}
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(snapshot.clone()))) {
|
||||
if (input.readInt() != MAGIC || input.readUnsignedByte() != VERSION) {
|
||||
throw invalidSnapshot();
|
||||
}
|
||||
String principalId = readText(input, MAXIMUM_PRINCIPAL_BYTES);
|
||||
String email = readNullableText(input, MAXIMUM_EMAIL_BYTES);
|
||||
Set<String> roles = readTokens(input, MAXIMUM_ROLES);
|
||||
Set<String> authorities = readTokens(input, MAXIMUM_AUTHORITIES);
|
||||
if (input.available() != 0) {
|
||||
throw invalidSnapshot();
|
||||
}
|
||||
return new PrimitiveAuthentication(principalId, email, roles, authorities);
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
throw invalidSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeTokens(DataOutputStream output, Set<String> values) throws IOException {
|
||||
output.writeInt(values.size());
|
||||
for (String value : values) {
|
||||
writeText(output, value, MAXIMUM_TOKEN_BYTES, "security token");
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> readTokens(DataInputStream input, int maximumCount)
|
||||
throws IOException {
|
||||
int count = input.readInt();
|
||||
if (count < 0 || count > maximumCount) {
|
||||
throw invalidSnapshot();
|
||||
}
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (int index = 0; index < count; index++) {
|
||||
if (!values.add(readText(input, MAXIMUM_TOKEN_BYTES))) {
|
||||
throw invalidSnapshot();
|
||||
}
|
||||
}
|
||||
return Set.copyOf(values);
|
||||
}
|
||||
|
||||
private static Set<String> boundedTokens(
|
||||
Collection<String> values, int maximumCount, String field) {
|
||||
if (values == null || values.size() > maximumCount) {
|
||||
throw new IllegalArgumentException(field + " exceed the configured count bound");
|
||||
}
|
||||
TreeSet<String> bounded = new TreeSet<>();
|
||||
for (String value : values) {
|
||||
requireBoundedText(value, MAXIMUM_TOKEN_BYTES, field);
|
||||
bounded.add(value);
|
||||
}
|
||||
return Set.copyOf(bounded);
|
||||
}
|
||||
|
||||
private static void writeNullableText(
|
||||
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
|
||||
output.writeBoolean(value != null);
|
||||
if (value != null) {
|
||||
writeText(output, value, maximumBytes, field);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readNullableText(DataInputStream input, int maximumBytes)
|
||||
throws IOException {
|
||||
return input.readBoolean() ? readText(input, maximumBytes) : null;
|
||||
}
|
||||
|
||||
private static void writeText(
|
||||
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
|
||||
byte[] encoded = requireBoundedText(value, maximumBytes, field);
|
||||
output.writeInt(encoded.length);
|
||||
output.write(encoded);
|
||||
}
|
||||
|
||||
private static String readText(DataInputStream input, int maximumBytes) throws IOException {
|
||||
int length = input.readInt();
|
||||
if (length < 1 || length > maximumBytes || length > input.available()) {
|
||||
throw new EOFException("invalid security context text length");
|
||||
}
|
||||
byte[] encoded = input.readNBytes(length);
|
||||
String value = new String(encoded, StandardCharsets.UTF_8);
|
||||
byte[] canonical = requireBoundedText(value, maximumBytes, "decoded value");
|
||||
if (!java.util.Arrays.equals(canonical, encoded)) {
|
||||
throw invalidSnapshot();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static byte[] requireBoundedText(String value, int maximumBytes, String field) {
|
||||
if (value == null || value.isBlank() || value.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException(field + " must be non-blank text without controls");
|
||||
}
|
||||
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (encoded.length > maximumBytes) {
|
||||
throw new IllegalArgumentException(field + " exceeds the UTF-8 byte bound");
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
private static IllegalArgumentException invalidSnapshot() {
|
||||
return new IllegalArgumentException("security context snapshot is corrupt or incompatible");
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static final class CommitSaveResponseWrapper
|
||||
extends SaveContextOnUpdateOrErrorResponseWrapper {
|
||||
|
||||
private final HttpServletRequest request;
|
||||
|
||||
private CommitSaveResponseWrapper(HttpServletResponse response, HttpServletRequest request) {
|
||||
super(response, true);
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void saveContext(SecurityContext context) {
|
||||
saveSnapshot(context, request);
|
||||
}
|
||||
|
||||
private void reconcileFinalContext(SecurityContext context) {
|
||||
saveContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static final class AsyncAwareRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
private final CommitSaveResponseWrapper response;
|
||||
|
||||
private AsyncAwareRequestWrapper(
|
||||
HttpServletRequest request, CommitSaveResponseWrapper response) {
|
||||
super(request);
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncContext startAsync() {
|
||||
response.disableSaveOnResponseCommitted();
|
||||
return super.startAsync();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncContext startAsync(ServletRequest request, ServletResponse response) {
|
||||
this.response.disableSaveOnResponseCommitted();
|
||||
return super.startAsync(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PrimitiveAuthentication {
|
||||
|
||||
private final String principalId;
|
||||
private final String email;
|
||||
private final Set<String> roles;
|
||||
private final Set<String> authorities;
|
||||
|
||||
private PrimitiveAuthentication(
|
||||
String principalId, String email, Set<String> roles, Set<String> authorities) {
|
||||
this.principalId = principalId;
|
||||
this.email = email;
|
||||
this.roles = roles;
|
||||
this.authorities = authorities;
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
|
||||
import org.springframework.session.web.http.CookieSerializer;
|
||||
import org.springframework.session.web.http.DefaultCookieSerializer;
|
||||
|
||||
/** Provider-neutral servlet session filter and hardened host-only cookie composition. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableSpringHttpSession
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.security.auth-mode",
|
||||
havingValue = "redis-session",
|
||||
matchIfMissing = false)
|
||||
public class RedisSessionWebConfig {
|
||||
|
||||
@Bean
|
||||
CookieSerializer sessionCookieSerializer(SecuritySettings settings) {
|
||||
SecuritySettings.SessionCookieSettings policy = settings.session();
|
||||
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
|
||||
serializer.setCookieName(policy.cookieName());
|
||||
serializer.setUseSecureCookie(policy.secure());
|
||||
serializer.setUseHttpOnlyCookie(policy.httpOnly());
|
||||
serializer.setSameSite(policy.sameSite());
|
||||
serializer.setCookiePath(policy.path());
|
||||
serializer.setCookieMaxAge(-1);
|
||||
serializer.setUseBase64Encoding(true);
|
||||
// No domain or domain pattern is configured: the session cookie remains host-only.
|
||||
return serializer;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A path that needs more than authentication.
|
||||
*
|
||||
* <p>The base chain ends in {@code anyRequest().authenticated()}, which is the right default for a
|
||||
* data plane and the wrong one for a management plane: it makes every authenticated caller a
|
||||
* potential administrator, and an application-level policy consulted later cannot recover from a
|
||||
* transport that already let the request through.
|
||||
*
|
||||
* <p>Modules that own a privileged surface contribute one of these instead of assembling a second
|
||||
* filter chain. A second chain would have to restate the whole authentication mechanism — JWT
|
||||
* decoding, session handling, the envelope entry point — and any drift between the two copies is a
|
||||
* silent authorization hole.
|
||||
*
|
||||
* @param pathPattern Ant-style pattern the rule applies to, for example {@code /internal/x/**}
|
||||
* @param requiredAuthorities any one of which admits the request; never empty
|
||||
*/
|
||||
public record RestrictedPathRule(String pathPattern, List<String> requiredAuthorities) {
|
||||
|
||||
public RestrictedPathRule {
|
||||
Objects.requireNonNull(pathPattern, "pathPattern");
|
||||
Objects.requireNonNull(requiredAuthorities, "requiredAuthorities");
|
||||
if (pathPattern.isBlank()) {
|
||||
throw new IllegalArgumentException("pathPattern must be non-blank");
|
||||
}
|
||||
if (requiredAuthorities.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"requiredAuthorities must not be empty: a rule that requires nothing is weaker than the "
|
||||
+ "authenticated default it replaces");
|
||||
}
|
||||
requiredAuthorities = List.copyOf(requiredAuthorities);
|
||||
}
|
||||
|
||||
String[] authorities() {
|
||||
return requiredAuthorities.toArray(new String[0]);
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
|
||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final SecuritySettings securitySettings;
|
||||
private final CorsSettings corsSettings;
|
||||
private final JwtToAuthenticatedPrincipalConverter jwtConverter;
|
||||
|
||||
public SecurityConfig(
|
||||
SecuritySettings securitySettings,
|
||||
CorsSettings corsSettings,
|
||||
JwtToAuthenticatedPrincipalConverter jwtConverter) {
|
||||
this.securitySettings = securitySettings;
|
||||
this.corsSettings = corsSettings;
|
||||
this.jwtConverter = jwtConverter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityErrorClassifier securityErrorClassifier() {
|
||||
return new SecurityErrorClassifier();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationEntryPoint authenticationEntryPoint(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
return new EnvelopeAuthenticationEntryPoint(classifier, objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AccessDeniedHandler accessDeniedHandler(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
return new EnvelopeAccessDeniedHandler(classifier, objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.security.auth-mode",
|
||||
havingValue = "redis-session",
|
||||
matchIfMissing = false)
|
||||
PrimitiveSessionSecurityContextRepository primitiveSessionSecurityContextRepository() {
|
||||
return new PrimitiveSessionSecurityContextRepository();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(
|
||||
HttpSecurity http,
|
||||
AuthenticationEntryPoint authenticationEntryPoint,
|
||||
AccessDeniedHandler accessDeniedHandler,
|
||||
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
|
||||
sessionSecurityContextRepository,
|
||||
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths)
|
||||
throws Exception {
|
||||
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
|
||||
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
|
||||
http.cors(c -> c.configurationSource(corsConfigurationSource()))
|
||||
// Disable Spring Security's default Cache-Control writer; CacheControlFilter
|
||||
// owns the cache header policy. See README for the design rationale.
|
||||
.headers(headers -> headers.cacheControl(cache -> cache.disable()))
|
||||
// This is an API boundary: never persist framework SavedRequest graphs in a session.
|
||||
.requestCache(cache -> cache.disable())
|
||||
.authorizeHttpRequests(
|
||||
auth -> {
|
||||
if (publicPaths.length > 0) {
|
||||
auth.requestMatchers(publicPaths).permitAll();
|
||||
}
|
||||
// Ordered before the authenticated catch-all: a management path must be refused at
|
||||
// the transport, not by an application policy the request has already passed.
|
||||
for (RestrictedPathRule rule : restricted) {
|
||||
auth.requestMatchers(rule.pathPattern()).hasAnyAuthority(rule.authorities());
|
||||
}
|
||||
auth.anyRequest().authenticated();
|
||||
})
|
||||
// The entry point and access-denied handler are set on both exceptionHandling and
|
||||
// oauth2ResourceServer so every filter resolves to the same Envelope writer.
|
||||
// See README for the design rationale.
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
ex.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler));
|
||||
if (securitySettings.authMode() == SecuritySettings.AuthenticationMode.JWT) {
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(
|
||||
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.oauth2ResourceServer(
|
||||
oauth ->
|
||||
oauth
|
||||
.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler)
|
||||
.withObjectPostProcessor(forwardServiceFailuresTo(authenticationEntryPoint))
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
|
||||
} else {
|
||||
SecuritySettings.SessionCookieSettings sessionSettings = securitySettings.session();
|
||||
CookieCsrfTokenRepository csrfRepository = new CookieCsrfTokenRepository();
|
||||
csrfRepository.setCookieName(sessionSettings.csrfCookieName());
|
||||
csrfRepository.setHeaderName(sessionSettings.csrfHeaderName());
|
||||
csrfRepository.setCookieCustomizer(
|
||||
cookie ->
|
||||
cookie
|
||||
.secure(true)
|
||||
.httpOnly(false)
|
||||
.sameSite(sessionSettings.sameSite())
|
||||
.path(sessionSettings.path()));
|
||||
CsrfTokenRequestAttributeHandler csrfRequestHandler = new CsrfTokenRequestAttributeHandler();
|
||||
http.csrf(
|
||||
csrf ->
|
||||
csrf.csrfTokenRepository(csrfRepository)
|
||||
.csrfTokenRequestHandler(csrfRequestHandler))
|
||||
.sessionManagement(
|
||||
session ->
|
||||
session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.sessionFixation(fixation -> fixation.migrateSession()))
|
||||
.securityContext(
|
||||
securityContext ->
|
||||
securityContext
|
||||
.securityContextRepository(sessionSecurityContextRepository.getObject())
|
||||
.requireExplicitSave(false));
|
||||
}
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
if (!corsSettings.enabled()) {
|
||||
return source; // no patterns registered -> Spring uses null config -> CORS inactive
|
||||
}
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(corsSettings.allowedOrigins());
|
||||
cfg.setAllowedMethods(corsSettings.allowedMethods());
|
||||
cfg.setAllowedHeaders(corsSettings.allowedHeaders());
|
||||
cfg.setAllowCredentials(corsSettings.allowCredentials());
|
||||
cfg.setMaxAge(corsSettings.maxAgeSeconds());
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
private static ObjectPostProcessor<BearerTokenAuthenticationFilter> forwardServiceFailuresTo(
|
||||
AuthenticationEntryPoint authenticationEntryPoint) {
|
||||
return new ObjectPostProcessor<>() {
|
||||
@Override
|
||||
public <O extends BearerTokenAuthenticationFilter> O postProcess(O filter) {
|
||||
filter.setAuthenticationFailureHandler(
|
||||
(request, response, exception) ->
|
||||
authenticationEntryPoint.commence(request, response, exception));
|
||||
return filter;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.util.Locale;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidationException;
|
||||
|
||||
/**
|
||||
* Classifies a resource-server security failure into a fine-grained {@link OperationalError} by
|
||||
* inspecting the exception graph and validator/Nimbus message text. Unmapped failures fall back to
|
||||
* the generic, safe {@code AUTH_TOKEN_MALFORMED} (401) rather than a 500. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class SecurityErrorClassifier {
|
||||
|
||||
/** Classifies an authentication (401-family) failure reaching the AuthenticationEntryPoint. */
|
||||
public OperationalError classifyAuthentication(AuthenticationException ex) {
|
||||
OperationalError byCause = classifyByCause(ex.getCause());
|
||||
if (byCause != null) {
|
||||
return byCause;
|
||||
}
|
||||
if (ex instanceof OAuth2AuthenticationException oauth) {
|
||||
OperationalError byError = classifyByText(describe(oauth.getError()));
|
||||
return byError != null ? byError : OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
if (ex instanceof InsufficientAuthenticationException) {
|
||||
return OperationalError.AUTH_TOKEN_MISSING;
|
||||
}
|
||||
// Unmapped authentication failure: a generic, safe 401 — never an unclassified 500.
|
||||
return OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
|
||||
/** Classifies an authorization (403-family) failure reaching the AccessDeniedHandler. */
|
||||
public OperationalError classifyAccessDenied(AccessDeniedException ex) {
|
||||
return OperationalError.AUTHZ_INSUFFICIENT_PERMISSION;
|
||||
}
|
||||
|
||||
private OperationalError classifyByCause(Throwable cause) {
|
||||
if (cause == null) {
|
||||
return null;
|
||||
}
|
||||
if (cause instanceof JwtDecoderConfig.AuthenticationKeyServiceUnavailableException) {
|
||||
return OperationalError.AUTH_JWKS_UNAVAILABLE;
|
||||
}
|
||||
if (cause instanceof JwtDecoderConfig.AuthenticationDecoderMisconfigurationException) {
|
||||
return OperationalError.INTERNAL_AUTH_MISCONFIGURATION;
|
||||
}
|
||||
if (cause instanceof JwtValidationException validation) {
|
||||
// A JWKS retrieval failure can surface wrapped in validation errors too.
|
||||
OperationalError fromText = null;
|
||||
for (OAuth2Error error : validation.getErrors()) {
|
||||
OperationalError mapped = classifyByText(describe(error));
|
||||
fromText = higherPriority(fromText, mapped);
|
||||
}
|
||||
return fromText != null ? fromText : OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
// BadJwtException extends JwtException; both carry the decode/signature/kid/JWKS message.
|
||||
return classifyByText(cause.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a single validator/decoder message to a code. Ordered, narrow heuristics; returns {@code
|
||||
* null} when nothing matches so callers can fall back.
|
||||
*/
|
||||
private OperationalError classifyByText(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String m = raw.toLowerCase(Locale.ROOT);
|
||||
// JWKS endpoint outage is a transient dependency failure (check before generic decode text).
|
||||
if (m.contains("jwk set") || m.contains("jwk source") || m.contains("jwkset")) {
|
||||
return OperationalError.AUTH_JWKS_UNAVAILABLE;
|
||||
}
|
||||
if (m.contains("expired") || m.contains("jwt expired")) {
|
||||
return OperationalError.AUTH_TOKEN_EXPIRED;
|
||||
}
|
||||
if (m.contains("iss claim") || m.contains("issuer")) {
|
||||
return OperationalError.AUTH_ISSUER_MISMATCH;
|
||||
}
|
||||
if (m.contains("aud claim") || m.contains("audience")) {
|
||||
return OperationalError.AUTH_AUDIENCE_MISMATCH;
|
||||
}
|
||||
if (m.contains("kid") || m.contains("matching key") || m.contains("key id")) {
|
||||
return OperationalError.AUTH_KID_UNKNOWN;
|
||||
}
|
||||
if (m.contains("signature") || m.contains("signed jwt rejected")) {
|
||||
return OperationalError.AUTH_TOKEN_INVALID_SIGNATURE;
|
||||
}
|
||||
if (m.contains("malformed")
|
||||
|| m.contains("invalid jwt")
|
||||
|| m.contains("invalid compact")
|
||||
|| m.contains("decode")) {
|
||||
return OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence among multiple simultaneous validation failures. Expiry is the most common
|
||||
* operational case and is reported first; then issuer, then audience, then anything else.
|
||||
*/
|
||||
private OperationalError higherPriority(OperationalError current, OperationalError candidate) {
|
||||
if (candidate == null) {
|
||||
return current;
|
||||
}
|
||||
if (current == null) {
|
||||
return candidate;
|
||||
}
|
||||
return rank(candidate) < rank(current) ? candidate : current;
|
||||
}
|
||||
|
||||
private int rank(OperationalError e) {
|
||||
return switch (e) {
|
||||
case AUTH_TOKEN_EXPIRED -> 0;
|
||||
case AUTH_ISSUER_MISMATCH -> 1;
|
||||
case AUTH_AUDIENCE_MISMATCH -> 2;
|
||||
default -> 3;
|
||||
};
|
||||
}
|
||||
|
||||
private String describe(OAuth2Error error) {
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
String description = error.getDescription();
|
||||
return description != null ? description : error.getErrorCode();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Web-adapter implementation of the application {@link AuthorizationPort}.
|
||||
*
|
||||
* <p>Resolves the caller's raw roles to an effective permission set via {@link
|
||||
* RolePermissionRegistry} and denies (with {@link AuthorizationDeniedException}) when the required
|
||||
* permission is absent. See README for the design rationale.
|
||||
*/
|
||||
@Component
|
||||
public class AuthorizationAdapter implements AuthorizationPort {
|
||||
|
||||
private final RolePermissionRegistry registry;
|
||||
|
||||
public AuthorizationAdapter(RolePermissionRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requirePermission(AuthorizationPrincipal principal, Permission required) {
|
||||
Set<Permission> effective = registry.effectivePermissions(principal.roles());
|
||||
if (!effective.contains(required)) {
|
||||
throw new AuthorizationDeniedException(principal.subject(), required);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.springframework.aop.Advisor;
|
||||
import org.springframework.aop.Pointcut;
|
||||
import org.springframework.aop.support.Pointcuts;
|
||||
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Role;
|
||||
import org.springframework.security.authorization.AuthorizationManager;
|
||||
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
|
||||
/**
|
||||
* Wires the {@link RequiresPermission} enforcement point into Spring method security.
|
||||
*
|
||||
* <p>{@code @EnableMethodSecurity(prePostEnabled = false)} enables the method-security
|
||||
* infrastructure without the {@code @PreAuthorize}/{@code @PostAuthorize} interceptors, leaving the
|
||||
* custom advisor below as the only authorization advice. See README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableMethodSecurity(prePostEnabled = false)
|
||||
public class MethodSecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||
static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) {
|
||||
AuthorizationManager<MethodInvocation> manager =
|
||||
new RequiresPermissionAuthorizationManager(authorizationPort);
|
||||
|
||||
Pointcut onMethod = AnnotationMatchingPointcut.forMethodAnnotation(RequiresPermission.class);
|
||||
Pointcut onClass = AnnotationMatchingPointcut.forClassAnnotation(RequiresPermission.class);
|
||||
Pointcut pointcut = Pointcuts.union(onMethod, onClass);
|
||||
|
||||
return new AuthorizationManagerBeforeMethodInterceptor(pointcut, manager);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.security.authorization.AuthorizationDecision;
|
||||
import org.springframework.security.authorization.AuthorizationManager;
|
||||
import org.springframework.security.authorization.AuthorizationResult;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
/**
|
||||
* Spring-aware enforcement mechanism for {@link RequiresPermission}.
|
||||
*
|
||||
* <p>Reads the {@link RequiresPermission} annotation off the intercepted method (or its declaring
|
||||
* type), maps the current {@link Authentication} to the framework-free {@link
|
||||
* AuthorizationPrincipal}, and delegates the decision to the application {@link AuthorizationPort}.
|
||||
* A denial from the port becomes a denied {@link AuthorizationDecision}; an absent annotation
|
||||
* returns {@code null} to abstain. See README for the design rationale.
|
||||
*/
|
||||
public final class RequiresPermissionAuthorizationManager
|
||||
implements AuthorizationManager<MethodInvocation> {
|
||||
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public RequiresPermissionAuthorizationManager(AuthorizationPort authorizationPort) {
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthorizationResult authorize(
|
||||
Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
|
||||
RequiresPermission annotation = findAnnotation(invocation);
|
||||
if (annotation == null) {
|
||||
return null; // not guarded by this manager — abstain
|
||||
}
|
||||
Permission required = Permission.parse(annotation.value());
|
||||
|
||||
Authentication auth = authentication.get();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
try {
|
||||
authorizationPort.requirePermission(toPrincipal(auth), required);
|
||||
return new AuthorizationDecision(true);
|
||||
} catch (AuthorizationDeniedException denied) {
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
}
|
||||
|
||||
private RequiresPermission findAnnotation(MethodInvocation invocation) {
|
||||
Method method = invocation.getMethod();
|
||||
RequiresPermission onMethod = AnnotationUtils.findAnnotation(method, RequiresPermission.class);
|
||||
if (onMethod != null) {
|
||||
return onMethod;
|
||||
}
|
||||
Class<?> targetClass =
|
||||
invocation.getThis() != null
|
||||
? AopUtils.getTargetClass(invocation.getThis())
|
||||
: method.getDeclaringClass();
|
||||
return AnnotationUtils.findAnnotation(targetClass, RequiresPermission.class);
|
||||
}
|
||||
|
||||
private AuthorizationPrincipal toPrincipal(Authentication auth) {
|
||||
if (auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
return new AuthorizationPrincipal(user.idpUserId(), user.roles());
|
||||
}
|
||||
// Any other principal type carries no resolvable roles → fail-closed.
|
||||
return new AuthorizationPrincipal(auth.getName(), Set.of());
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* App-side role→permission mapping source.
|
||||
*
|
||||
* <p>Bound from {@code ca-skeleton.authz.role-permissions.<role> = [resource:action, ...]}. Keys
|
||||
* are <em>raw</em> IdP role names (no {@code ROLE_} prefix), e.g.:
|
||||
*
|
||||
* <pre>
|
||||
* ca-skeleton:
|
||||
* authz:
|
||||
* role-permissions:
|
||||
* user: [worklog:read, worklog:write]
|
||||
* admin: [worklog:read, worklog:write, worklog:close]
|
||||
* </pre>
|
||||
*
|
||||
* <p>See README for the design rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.authz")
|
||||
public record RolePermissionPolicy(Map<String, List<String>> rolePermissions) {
|
||||
|
||||
public RolePermissionPolicy {
|
||||
rolePermissions =
|
||||
rolePermissions == null
|
||||
? Map.of()
|
||||
: rolePermissions.entrySet().stream()
|
||||
.collect(
|
||||
Collectors.toUnmodifiableMap(
|
||||
Map.Entry::getKey, entry -> List.copyOf(entry.getValue())));
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Resolves a caller's raw roles to an effective {@link Permission} set.
|
||||
*
|
||||
* <p>Built once from {@link RolePermissionPolicy} at startup. Role keys are normalized to lower
|
||||
* case so a lookup is case-insensitive. Permissions are the explicitly enumerated set per role;
|
||||
* wildcards are unsupported. An unknown role, an empty role set, or an empty registry all resolve
|
||||
* to zero permissions. See README for the design rationale.
|
||||
*/
|
||||
@Component
|
||||
public class RolePermissionRegistry {
|
||||
|
||||
private final Map<String, Set<Permission>> permissionsByRole;
|
||||
|
||||
public RolePermissionRegistry(RolePermissionPolicy properties) {
|
||||
Map<String, Set<Permission>> resolved = new HashMap<>();
|
||||
properties
|
||||
.rolePermissions()
|
||||
.forEach(
|
||||
(role, tokens) -> {
|
||||
Set<Permission> permissions =
|
||||
tokens.stream().map(Permission::parse).collect(Collectors.toUnmodifiableSet());
|
||||
resolved.put(normalize(role), permissions);
|
||||
});
|
||||
this.permissionsByRole = Map.copyOf(resolved);
|
||||
}
|
||||
|
||||
/** Union of the permissions granted by each of {@code roles}; empty if none/unknown. */
|
||||
public Set<Permission> effectivePermissions(Set<String> roles) {
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
return roles.stream()
|
||||
.filter(role -> role != null && !role.isBlank())
|
||||
.map(role -> permissionsByRole.getOrDefault(normalize(role), Set.of()))
|
||||
.flatMap(Set::stream)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
private static String normalize(String role) {
|
||||
return role.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.caskeleton.adapter.inbound.web.conditional;
|
||||
|
||||
/**
|
||||
* Weak-ETag derivation and conditional-request matching for HTTP-layer optimistic concurrency /
|
||||
* cache validation. See README for the design rationale.
|
||||
*
|
||||
* <p>An entity's optimistic-lock version is the ETag source: {@link #weakFromVersion} yields {@code
|
||||
* W/"<version>"}. Reads emit it as the {@code ETag} header; a write carrying {@code If-Match} is
|
||||
* accepted only when {@link #matches} is {@code true}, otherwise the controller raises {@link
|
||||
* PreconditionFailedException} (→ 412); a read carrying {@code If-None-Match} that {@link #matches}
|
||||
* returns 304 (no body).
|
||||
*/
|
||||
public final class ETags {
|
||||
|
||||
private static final String WILDCARD = "*";
|
||||
|
||||
private ETags() {}
|
||||
|
||||
/** {@code W/"<version>"} weak validator from an optimistic-lock version. */
|
||||
public static String weakFromVersion(long version) {
|
||||
return "W/\"" + version + "\"";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient conditional match: {@code true} when {@code header} is {@code *} or any comma-separated
|
||||
* candidate's opaque value equals {@code etag}'s opaque value. Null/blank header → {@code false}
|
||||
* (no precondition supplied).
|
||||
*/
|
||||
public static boolean matches(
|
||||
String header, String etag) { // e.g. If-None-Match: W/"3" on reads, If-Match: W/"3" on writes
|
||||
if (header == null || header.isBlank() || etag == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = header.trim();
|
||||
if (WILDCARD.equals(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
String target = opaque(etag);
|
||||
int candidateStart = 0;
|
||||
boolean inQuotes = false;
|
||||
boolean matched = false;
|
||||
for (int index = 0; index < trimmed.length(); index++) {
|
||||
char current = trimmed.charAt(index);
|
||||
if (current == '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (current == ',' && !inQuotes) {
|
||||
String candidate = trimmed.substring(candidateStart, index);
|
||||
if (!isWellFormedCandidate(candidate)) {
|
||||
return false;
|
||||
}
|
||||
matched |= opaque(candidate).equals(target);
|
||||
candidateStart = index + 1;
|
||||
}
|
||||
}
|
||||
if (inQuotes) {
|
||||
return false;
|
||||
}
|
||||
String candidate = trimmed.substring(candidateStart);
|
||||
if (!isWellFormedCandidate(candidate)) {
|
||||
return false;
|
||||
}
|
||||
return matched || opaque(candidate).equals(target);
|
||||
}
|
||||
|
||||
private static boolean isWellFormedCandidate(String raw) {
|
||||
String value = raw.trim();
|
||||
if (value.startsWith("W/")) {
|
||||
value = value.substring(2).trim();
|
||||
}
|
||||
int firstQuote = value.indexOf('"');
|
||||
if (firstQuote < 0) {
|
||||
return true;
|
||||
}
|
||||
return firstQuote == 0
|
||||
&& value.length() >= 2
|
||||
&& value.charAt(value.length() - 1) == '"'
|
||||
&& value.substring(1, value.length() - 1).indexOf('"') < 0;
|
||||
}
|
||||
|
||||
/** Strips the {@code W/} weak marker and surrounding double quotes. */
|
||||
private static String opaque(String raw) {
|
||||
String v = raw.trim();
|
||||
if (v.startsWith("W/")) {
|
||||
v = v.substring(2).trim();
|
||||
}
|
||||
if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
|
||||
v = v.substring(1, v.length() - 1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.inbound.web.conditional;
|
||||
|
||||
/**
|
||||
* Raised when a write request's {@code If-Match} validator does not match the current resource
|
||||
* ETag. The global handler maps it to {@code OperationalError.PRECONDITION_FAILED} (HTTP 412). See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public class PreconditionFailedException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public PreconditionFailedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import org.openapitools.jackson.nullable.JsonNullable;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.JsonGenerator;
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.core.JsonToken;
|
||||
import tools.jackson.databind.BeanProperty;
|
||||
import tools.jackson.databind.DeserializationContext;
|
||||
import tools.jackson.databind.JavaType;
|
||||
import tools.jackson.databind.SerializationContext;
|
||||
import tools.jackson.databind.ValueDeserializer;
|
||||
import tools.jackson.databind.ValueSerializer;
|
||||
import tools.jackson.databind.module.SimpleModule;
|
||||
|
||||
/**
|
||||
* Registers Jackson 3 handlers for {@link JsonNullable}. The upstream jackson-databind-nullable
|
||||
* module is still Jackson 2 based, so the template keeps a narrow local adapter for PATCH request
|
||||
* DTOs.
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonNullableConfig {
|
||||
|
||||
@Bean
|
||||
public SimpleModule jsonNullableModule() {
|
||||
SimpleModule module = new SimpleModule("JsonNullableJackson3Module");
|
||||
module.addDeserializer(JsonNullable.class, new JsonNullableValueDeserializer());
|
||||
addJsonNullableSerializer(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private static void addJsonNullableSerializer(SimpleModule module) {
|
||||
module.addSerializer((Class) JsonNullable.class, new JsonNullableValueSerializer());
|
||||
}
|
||||
|
||||
static final class JsonNullableValueDeserializer extends ValueDeserializer<JsonNullable<Object>> {
|
||||
|
||||
private final JavaType valueType;
|
||||
private final ValueDeserializer<Object> valueDeserializer;
|
||||
|
||||
JsonNullableValueDeserializer() {
|
||||
this(null, null);
|
||||
}
|
||||
|
||||
private JsonNullableValueDeserializer(
|
||||
JavaType valueType, ValueDeserializer<Object> valueDeserializer) {
|
||||
this.valueType = valueType;
|
||||
this.valueDeserializer = valueDeserializer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ValueDeserializer<?> createContextual(
|
||||
DeserializationContext ctxt, BeanProperty property) {
|
||||
JavaType contextualType =
|
||||
property == null ? ctxt.constructType(Object.class) : property.getType();
|
||||
JavaType referencedType =
|
||||
contextualType.containedTypeCount() == 0
|
||||
? ctxt.constructType(Object.class)
|
||||
: contextualType.containedTypeOrUnknown(0);
|
||||
return new JsonNullableValueDeserializer(
|
||||
referencedType, ctxt.findContextualValueDeserializer(referencedType, property));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNullable<Object> deserialize(JsonParser parser, DeserializationContext ctxt)
|
||||
throws JacksonException {
|
||||
if (parser.currentToken() == JsonToken.VALUE_NULL) {
|
||||
return JsonNullable.of(null);
|
||||
}
|
||||
Object value =
|
||||
valueDeserializer == null
|
||||
? ctxt.readValue(
|
||||
parser, valueType == null ? ctxt.constructType(Object.class) : valueType)
|
||||
: valueDeserializer.deserialize(parser, ctxt);
|
||||
return JsonNullable.of(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getNullValue(DeserializationContext ctxt) {
|
||||
return JsonNullable.of(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getAbsentValue(DeserializationContext ctxt) {
|
||||
return JsonNullable.undefined();
|
||||
}
|
||||
}
|
||||
|
||||
static final class JsonNullableValueSerializer extends ValueSerializer<JsonNullable<Object>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonNullable<Object> value, JsonGenerator generator, SerializationContext ctxt)
|
||||
throws JacksonException {
|
||||
if (value == null || !value.isPresent()) {
|
||||
generator.writeNull();
|
||||
return;
|
||||
}
|
||||
ctxt.writeValue(generator, value.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.media.ObjectSchema;
|
||||
import io.swagger.v3.oas.models.media.Schema;
|
||||
import java.util.Map;
|
||||
import org.springdoc.core.customizers.OpenApiCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Keeps transport-owned OpenAPI schema details stable across springdoc library upgrades.
|
||||
*
|
||||
* <p>{@code ApiError.details} is represented by {@code Object} in the shared response contract.
|
||||
* Springdoc 3 renders an untyped Java {@code Object} as an unconstrained OAS 3.1 schema. The public
|
||||
* HTTP contract remains object-shaped, so the web adapter restores that transport-specific type
|
||||
* without adding Swagger dependencies or annotations to {@code shared-contract}.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class OpenApiContractConfig {
|
||||
|
||||
// Swagger's Components.getSchemas() is declared with a raw Schema, so a parameterized local would
|
||||
// not compile against it. The rawness comes from the library, not from this code.
|
||||
@SuppressWarnings("rawtypes")
|
||||
@Bean
|
||||
OpenApiCustomizer apiErrorDetailsObjectSchemaCustomizer() {
|
||||
return openApi -> {
|
||||
Components components = openApi.getComponents();
|
||||
Map<String, Schema> schemas = components == null ? null : components.getSchemas();
|
||||
Schema<?> apiError = schemas == null ? null : schemas.get("ApiError");
|
||||
Map<String, Schema> properties = apiError == null ? null : apiError.getProperties();
|
||||
if (properties != null && properties.containsKey("details")) {
|
||||
properties.put("details", new ObjectSchema());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class PresentationWebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final PresentationSettings settings;
|
||||
|
||||
public PresentationWebConfig(PresentationSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configurePathMatch(PathMatchConfigurer configurer) {
|
||||
String prefix = settings.apiBasePath();
|
||||
if (prefix == null || prefix.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
configurer.addPathPrefix(prefix, c -> true);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.inbound.web.controller;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/healthcheck")
|
||||
public class HealthcheckController {
|
||||
|
||||
@GetMapping
|
||||
public Envelope<Map<String, String>> healthcheck() {
|
||||
return Envelope.ok(Map.of("status", "UP"), ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.inbound.web.cursor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Opaque, signed, time-bounded pagination cursor codec. A cursor is {@code base64url(iat + ":" +
|
||||
* payload)} plus an HMAC-SHA256 signature, so it is URL-safe, tamper-evident, and expires after a
|
||||
* fixed TTL (24h). Clients MUST treat the token as opaque. See README for the design rationale.
|
||||
*/
|
||||
public final class CursorCodec {
|
||||
|
||||
/** Default cursor TTL (24h). */
|
||||
public static final Duration DEFAULT_TTL = Duration.ofHours(24);
|
||||
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||
private static final char SEP = '.';
|
||||
private static final Base64.Encoder ENC = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder DEC = Base64.getUrlDecoder();
|
||||
|
||||
private final byte[] key;
|
||||
private final Duration ttl;
|
||||
|
||||
public CursorCodec(byte[] key, Duration ttl) {
|
||||
if (key == null || key.length < 16) {
|
||||
throw new IllegalArgumentException("cursor HMAC key must be at least 16 bytes");
|
||||
}
|
||||
this.key = key.clone();
|
||||
this.ttl = ttl;
|
||||
}
|
||||
|
||||
/** Dev / test factory — NOT for production. */
|
||||
public static CursorCodec withDevKey() {
|
||||
return new CursorCodec(
|
||||
"ca-skeleton-dev-cursor-key-0001".getBytes(StandardCharsets.UTF_8), DEFAULT_TTL);
|
||||
}
|
||||
|
||||
/** Encodes an opaque payload string + issue instant into a signed URL-safe token. */
|
||||
public String encode(String payload, Instant issuedAt) {
|
||||
String body = issuedAt.getEpochSecond() + ":" + payload;
|
||||
String b64Body = ENC.encodeToString(body.getBytes(StandardCharsets.UTF_8));
|
||||
return b64Body + SEP + ENC.encodeToString(sign(b64Body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies signature + TTL and returns the original payload, or throws {@link CursorException}.
|
||||
*/
|
||||
public String decode(String token, Instant now) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new CursorException("cursor token is missing");
|
||||
}
|
||||
int dot = token.lastIndexOf(SEP);
|
||||
if (dot <= 0 || dot == token.length() - 1) {
|
||||
throw new CursorException("cursor token is malformed");
|
||||
}
|
||||
String b64Body = token.substring(0, dot);
|
||||
byte[] presented;
|
||||
byte[] expected;
|
||||
String body;
|
||||
try {
|
||||
presented = DEC.decode(token.substring(dot + 1));
|
||||
expected = sign(b64Body);
|
||||
body = new String(DEC.decode(b64Body), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new CursorException("cursor token is malformed");
|
||||
}
|
||||
if (!MessageDigest.isEqual(expected, presented)) {
|
||||
throw new CursorException("cursor token signature is invalid");
|
||||
}
|
||||
int colon = body.indexOf(':');
|
||||
if (colon < 0) {
|
||||
throw new CursorException("cursor token payload is malformed");
|
||||
}
|
||||
long issuedAtEpoch;
|
||||
try {
|
||||
issuedAtEpoch = Long.parseLong(body.substring(0, colon));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new CursorException("cursor token payload is malformed");
|
||||
}
|
||||
if (now.getEpochSecond() - issuedAtEpoch > ttl.toSeconds()) {
|
||||
throw new CursorException("cursor token has expired");
|
||||
}
|
||||
return body.substring(colon + 1);
|
||||
}
|
||||
|
||||
private byte[] sign(String data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(key, HMAC_ALGORITHM));
|
||||
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HMAC computation failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.inbound.web.cursor;
|
||||
|
||||
/**
|
||||
* Raised when an opaque pagination cursor fails verification — tampered HMAC signature, malformed
|
||||
* encoding, or expired TTL. Controllers map it to 400 VALIDATION_FAILED and advise re-requesting
|
||||
* the first page.
|
||||
*/
|
||||
public class CursorException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public CursorException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.inbound.web.envelope;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.response.BulkEnvelope;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
/**
|
||||
* Wraps every JSON controller response in {@link Envelope} unless it is already an envelope
|
||||
* variant, so the wire shape is always {@code {success, data | error, traceId}}. See README for the
|
||||
* design rationale.
|
||||
*
|
||||
* <p>Skipped: already-{@link Envelope}/{@link BulkEnvelope} bodies, null/void (DELETE 204),
|
||||
* non-JSON content types.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class EnvelopeBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(
|
||||
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
if (body instanceof Envelope<?> || body instanceof BulkEnvelope<?>) {
|
||||
return body;
|
||||
}
|
||||
if (selectedContentType != null && !MediaType.APPLICATION_JSON.includes(selectedContentType)) {
|
||||
return body;
|
||||
}
|
||||
return Envelope.ok(body, ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
|
||||
final class ClientSafeErrorMessages {
|
||||
|
||||
private ClientSafeErrorMessages() {}
|
||||
|
||||
static String forOperational(ApiErrorCode code) {
|
||||
return switch (code.code()) {
|
||||
case "MAPPING_FAILED" -> "Request data could not be mapped";
|
||||
case "BAD_PARAMETER" -> "Request parameter is invalid";
|
||||
case "VALIDATION_FAILED" -> "Request validation failed";
|
||||
case "INVALID_TOKEN",
|
||||
"AUTH_TOKEN_MALFORMED",
|
||||
"AUTH_TOKEN_EXPIRED",
|
||||
"AUTH_TOKEN_INVALID_SIGNATURE",
|
||||
"AUTH_ISSUER_MISMATCH",
|
||||
"AUTH_AUDIENCE_MISMATCH",
|
||||
"AUTH_KID_UNKNOWN",
|
||||
"AUTH_CLAIM_MAPPING_FAILED" ->
|
||||
"Authentication token is invalid";
|
||||
case "UNAUTHENTICATED", "AUTH_TOKEN_MISSING" -> "Authentication is required";
|
||||
case "FORBIDDEN",
|
||||
"AUTHZ_INSUFFICIENT_PERMISSION",
|
||||
"AUTHZ_TENANT_MISMATCH",
|
||||
"ACTUATOR_FORBIDDEN" ->
|
||||
"Access is denied";
|
||||
case "AUTH_JWKS_UNAVAILABLE" ->
|
||||
"Authentication service temporarily unavailable, please retry";
|
||||
case "PRECONDITION_FAILED" -> "Resource state changed; refresh and retry";
|
||||
case "METHOD_NOT_ALLOWED" -> "HTTP method is not allowed for this route";
|
||||
case "NOT_ACCEPTABLE" -> "No acceptable response representation is available";
|
||||
case "PAYLOAD_TOO_LARGE" -> "Request payload exceeds the maximum allowed size";
|
||||
case "UNSUPPORTED_MEDIA_TYPE" -> "Request content type is not supported";
|
||||
case "ROUTE_NOT_FOUND" -> "Requested route was not found";
|
||||
case "ADAPTER_DISABLED", "INTERNAL_ERROR", "INTERNAL_AUTH_MISCONFIGURATION" ->
|
||||
"Internal server error";
|
||||
default -> forPersistence(code.category());
|
||||
};
|
||||
}
|
||||
|
||||
static String forPersistence(Category category) {
|
||||
return switch (category) {
|
||||
case TRANSIENT_DEPENDENCY -> "Service temporarily unavailable, please retry later";
|
||||
case CONFLICT -> "Request conflicted with the current state, please retry";
|
||||
case DATA_INTEGRITY -> "Request violates a data constraint";
|
||||
default -> "Internal server error";
|
||||
};
|
||||
}
|
||||
|
||||
static String forDependency(ApiErrorCode code) {
|
||||
return switch (code.code()) {
|
||||
case "DEPENDENCY_TIMEOUT" -> "Upstream service did not respond in time, please retry";
|
||||
case "DEPENDENCY_CONNECT_FAILED" -> "Upstream service unreachable, please retry";
|
||||
case "DEPENDENCY_DNS_FAILED" -> "Upstream service unreachable, please retry";
|
||||
case "DEPENDENCY_4XX_CLIENT" -> "Upstream service rejected the request";
|
||||
case "DEPENDENCY_5XX_SERVER" -> "Upstream service error, please retry";
|
||||
case "DEPENDENCY_CIRCUIT_OPEN" ->
|
||||
"Upstream service temporarily unavailable, please retry later";
|
||||
default -> forPersistence(code.category());
|
||||
};
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.Path;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.validation.FieldError;
|
||||
|
||||
/**
|
||||
* Builds bounded validation details without reflecting rejected values or interpolated messages.
|
||||
*/
|
||||
final class ClientSafeValidationDetails {
|
||||
|
||||
private static final Rule INVALID = new Rule("INVALID", "Invalid value");
|
||||
|
||||
private static final Map<String, Rule> RULES =
|
||||
Map.ofEntries(
|
||||
Map.entry("NotNull", new Rule("NOT_NULL", "Required value is missing")),
|
||||
Map.entry("NotBlank", new Rule("NOT_BLANK", "Value must not be blank")),
|
||||
Map.entry("NotEmpty", new Rule("NOT_EMPTY", "Value must not be empty")),
|
||||
Map.entry("Size", new Rule("SIZE", "Value size is outside the allowed range")),
|
||||
Map.entry("Min", new Rule("MIN", "Value is below the allowed minimum")),
|
||||
Map.entry("DecimalMin", new Rule("MIN", "Value is below the allowed minimum")),
|
||||
Map.entry("Max", new Rule("MAX", "Value exceeds the allowed maximum")),
|
||||
Map.entry("DecimalMax", new Rule("MAX", "Value exceeds the allowed maximum")),
|
||||
Map.entry("Positive", new Rule("POSITIVE", "Value must be positive")),
|
||||
Map.entry("PositiveOrZero", new Rule("POSITIVE_OR_ZERO", "Value must not be negative")),
|
||||
Map.entry("Negative", new Rule("NEGATIVE", "Value must be negative")),
|
||||
Map.entry("NegativeOrZero", new Rule("NEGATIVE_OR_ZERO", "Value must not be positive")),
|
||||
Map.entry("Pattern", new Rule("PATTERN", "Value has an invalid format")),
|
||||
Map.entry("Email", new Rule("EMAIL", "Value has an invalid format")),
|
||||
Map.entry("Past", new Rule("PAST", "Value must be in the past")),
|
||||
Map.entry(
|
||||
"PastOrPresent", new Rule("PAST_OR_PRESENT", "Value must not be in the future")),
|
||||
Map.entry("Future", new Rule("FUTURE", "Value must be in the future")),
|
||||
Map.entry(
|
||||
"FutureOrPresent", new Rule("FUTURE_OR_PRESENT", "Value must not be in the past")),
|
||||
Map.entry("AssertTrue", new Rule("ASSERT_TRUE", "Value must be true")),
|
||||
Map.entry("AssertFalse", new Rule("ASSERT_FALSE", "Value must be false")),
|
||||
Map.entry("typeMismatch", new Rule("TYPE_MISMATCH", "Value has an invalid type")));
|
||||
|
||||
private ClientSafeValidationDetails() {}
|
||||
|
||||
static Map<String, Object> from(ConstraintViolation<?> violation) {
|
||||
Annotation annotation = violation.getConstraintDescriptor().getAnnotation();
|
||||
Rule rule = ruleFor(annotation == null ? null : annotation.annotationType().getSimpleName());
|
||||
return detail(normalize(violation.getPropertyPath()), rule);
|
||||
}
|
||||
|
||||
static Map<String, Object> from(FieldError fieldError) {
|
||||
return detail(normalize(fieldError.getField()), ruleFor(fieldError.getCode()));
|
||||
}
|
||||
|
||||
private static Map<String, Object> detail(String field, Rule rule) {
|
||||
return Map.of("field", field, "code", rule.code(), "message", rule.message());
|
||||
}
|
||||
|
||||
private static Rule ruleFor(String rawCode) {
|
||||
if (rawCode == null || rawCode.isBlank()) {
|
||||
return INVALID;
|
||||
}
|
||||
int qualifier = rawCode.indexOf('.');
|
||||
String simpleCode = qualifier < 0 ? rawCode : rawCode.substring(0, qualifier);
|
||||
return RULES.getOrDefault(simpleCode, INVALID);
|
||||
}
|
||||
|
||||
private static String normalize(Path path) {
|
||||
if (path == null) {
|
||||
return "request";
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Path.Node node : path) {
|
||||
if (isPropertyName(node.getName())) {
|
||||
names.add(node.getName());
|
||||
}
|
||||
}
|
||||
return names.isEmpty() ? normalize(path.toString()) : String.join(".", names);
|
||||
}
|
||||
|
||||
private static String normalize(String rawPath) {
|
||||
if (rawPath == null || rawPath.isBlank()) {
|
||||
return "request";
|
||||
}
|
||||
StringBuilder withoutIterableParts = new StringBuilder(rawPath.length());
|
||||
int bracketDepth = 0;
|
||||
for (int i = 0; i < rawPath.length(); i++) {
|
||||
char current = rawPath.charAt(i);
|
||||
if (current == '[') {
|
||||
bracketDepth++;
|
||||
} else if (current == ']') {
|
||||
if (bracketDepth > 0) {
|
||||
bracketDepth--;
|
||||
}
|
||||
} else if (bracketDepth == 0) {
|
||||
withoutIterableParts.append(current);
|
||||
}
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
int segmentStart = 0;
|
||||
for (int i = 0; i <= withoutIterableParts.length(); i++) {
|
||||
if (i == withoutIterableParts.length() || withoutIterableParts.charAt(i) == '.') {
|
||||
String candidate = withoutIterableParts.substring(segmentStart, i);
|
||||
if (isPropertyName(candidate)) {
|
||||
names.add(candidate);
|
||||
}
|
||||
segmentStart = i + 1;
|
||||
}
|
||||
}
|
||||
return names.isEmpty() ? "request" : String.join(".", names);
|
||||
}
|
||||
|
||||
private static boolean isPropertyName(String candidate) {
|
||||
if (candidate == null || candidate.isBlank() || candidate.length() > 128) {
|
||||
return false;
|
||||
}
|
||||
if (!Character.isJavaIdentifierStart(candidate.charAt(0))) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 1; i < candidate.length(); i++) {
|
||||
if (!Character.isJavaIdentifierPart(candidate.charAt(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private record Rule(String code, String message) {}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.response.ApiError;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
/**
|
||||
* Builds a failure {@link Envelope} response from any {@link ApiErrorCode}, mapping the
|
||||
* framework-neutral {@code int httpStatus()} to Spring {@link HttpStatus}, carrying {@code
|
||||
* error.category}, and lifting the {@code meta} object (request/trace/correlation ids) from MDC via
|
||||
* {@link ResponseMetaFactory}.
|
||||
*/
|
||||
public final class ErrorResponseFactory {
|
||||
|
||||
private ErrorResponseFactory() {}
|
||||
|
||||
public static ResponseEntity<Envelope<Void>> envelope(
|
||||
ApiErrorCode code, String message, Object details) {
|
||||
return ResponseEntity.status(HttpStatus.valueOf(code.httpStatus()))
|
||||
.body(body(code, message, details));
|
||||
}
|
||||
|
||||
/** Body-only variant for ResponseEntityExceptionHandler hooks that set status separately. */
|
||||
public static Envelope<Void> body(ApiErrorCode code, String message, Object details) {
|
||||
ApiError err =
|
||||
details == null
|
||||
? ApiError.of(code.code(), code.category().name(), message, code.retryable())
|
||||
: ApiError.withDetails(
|
||||
code.code(), code.category().name(), message, code.retryable(), details);
|
||||
return Envelope.failure(err, ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.SecurityErrorClassifier;
|
||||
import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException;
|
||||
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
|
||||
import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyInFlightException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyRequestMismatchException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyScopeMissingException;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
/**
|
||||
* Skeleton-wide base error → {@link Envelope} converter. Handles operational, transport, and
|
||||
* security exceptions only; domain exceptions are handled by a separate
|
||||
* {@code @RestControllerAdvice} in the consuming module. See README for the design rationale.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/** Stateless classifier shared with the filter-layer access-denied handler. */
|
||||
private static final SecurityErrorClassifier ACCESS_DENIED_CLASSIFIER =
|
||||
new SecurityErrorClassifier();
|
||||
|
||||
/** Records span errors via a tracer-neutral seam (default {@link SpanErrorRecorder#NOOP}). */
|
||||
private final SpanErrorRecorder spanErrorRecorder;
|
||||
|
||||
/**
|
||||
* Spring entry point. Self-defaults to {@link SpanErrorRecorder#NOOP} when no {@code
|
||||
* SpanErrorRecorder} bean is present. See README for the design rationale.
|
||||
*/
|
||||
@Autowired
|
||||
public GlobalExceptionHandler(ObjectProvider<SpanErrorRecorder> spanErrorRecorderProvider) {
|
||||
this(spanErrorRecorderProvider.getIfAvailable(() -> SpanErrorRecorder.NOOP));
|
||||
}
|
||||
|
||||
/** Direct constructor for tests and explicit wiring (e.g. a capturing recorder). */
|
||||
public GlobalExceptionHandler(SpanErrorRecorder spanErrorRecorder) {
|
||||
this.spanErrorRecorder = spanErrorRecorder;
|
||||
}
|
||||
|
||||
@ExceptionHandler(MappingException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.MAPPING_FAILED,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.MAPPING_FAILED),
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime fail-fast for a disabled optional adapter that was invoked → 500 {@link
|
||||
* OperationalError#ADAPTER_DISABLED}. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(AdapterDisabledException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleAdapterDisabled(AdapterDisabledException ex) {
|
||||
log.error(
|
||||
"disabled optional adapter invoked at runtime: adapter={} (Layer 3 fail-fast)",
|
||||
ex.adapterName(),
|
||||
ex);
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.ADAPTER_DISABLED,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.ADAPTER_DISABLED),
|
||||
null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.BAD_PARAMETER,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.BAD_PARAMETER),
|
||||
null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleConstraintViolation(ConstraintViolationException ex) {
|
||||
List<Map<String, Object>> violations =
|
||||
ex.getConstraintViolations().stream().map(ClientSafeValidationDetails::from).toList();
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED, "Request validation failed", violations);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
Map<String, Object> details =
|
||||
ex.getRequiredType() == null
|
||||
? null
|
||||
: Map.of("expectedType", ex.getRequiredType().getSimpleName());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.BAD_PARAMETER, "Parameter '" + ex.getName() + "' is invalid", details);
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidBearerTokenException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleInvalidToken(InvalidBearerTokenException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.INVALID_TOKEN,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.INVALID_TOKEN),
|
||||
null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleUnauthenticated(AuthenticationException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.UNAUTHENTICATED,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.UNAUTHENTICATED),
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a method-security {@link AccessDeniedException} that escaped the controller, delegating
|
||||
* to {@link SecurityErrorClassifier} for the fine-grained authorization code. See README for the
|
||||
* design rationale.
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleForbidden(AccessDeniedException ex) {
|
||||
ApiErrorCode code = ACCESS_DENIED_CLASSIFIER.classifyAccessDenied(ex);
|
||||
return ErrorResponseFactory.envelope(code, ClientSafeErrorMessages.forOperational(code), null);
|
||||
}
|
||||
|
||||
/** Handles a failed {@code If-Match} precondition → 412 PRECONDITION_FAILED. */
|
||||
@ExceptionHandler(PreconditionFailedException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePreconditionFailed(PreconditionFailedException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.PRECONDITION_FAILED,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.PRECONDITION_FAILED),
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an out-of-bounds pagination / sort / filter parameter → 400 VALIDATION_FAILED carrying
|
||||
* the offending field + reason code.
|
||||
*/
|
||||
@ExceptionHandler(PageValidationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePageValidation(PageValidationException ex) {
|
||||
Map<String, Object> details = Map.of("field", ex.field(), "code", ex.reasonCode());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED, "Pagination parameter is invalid", details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a tampered / expired / malformed opaque cursor → 400 VALIDATION_FAILED advising the
|
||||
* client to re-request the first page.
|
||||
*/
|
||||
@ExceptionHandler(CursorException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleCursor(CursorException ex) {
|
||||
Map<String, Object> details = Map.of("field", "cursor", "code", "CURSOR_INVALID");
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED,
|
||||
"Cursor is invalid or expired; re-request the first page",
|
||||
details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a duplicate request whose original is still in flight → 409 IDEMPOTENT_IN_FLIGHT with a
|
||||
* fixed client-safe message. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyInFlightException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotentInFlight(IdempotencyInFlightException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.IDEMPOTENT_IN_FLIGHT,
|
||||
"A previous identical request is still being processed, please poll for result",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an {@code Idempotency-Key} reused with a different body → 422
|
||||
* IDEMPOTENT_REQUEST_MISMATCH with a client-safe message only.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyRequestMismatchException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotentMismatch(
|
||||
IdempotencyRequestMismatchException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.IDEMPOTENT_REQUEST_MISMATCH,
|
||||
"Idempotency key reused with different request body",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an idempotency key applied without a resolvable scope → 400 VALIDATION_FAILED. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyScopeMissingException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotencyScopeMissing(
|
||||
IdempotencyScopeMissingException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED,
|
||||
"Idempotency key cannot be applied to this request",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a pre-classified {@link PersistenceFailureException}: its {@link
|
||||
* PersistenceFailureException#errorCode()} sets the envelope code/status; the client message is a
|
||||
* category-derived safe string. Logs and traces receive only the stable classification because a
|
||||
* JDBC cause can contain SQL values, constraints, credentials, and endpoints.
|
||||
*/
|
||||
@ExceptionHandler(PersistenceFailureException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePersistenceFailure(PersistenceFailureException ex) {
|
||||
ApiErrorCode code = ex.errorCode();
|
||||
log.error(
|
||||
"persistence failure classified as {} (category={}, retryable={})",
|
||||
code.code(),
|
||||
code.category(),
|
||||
code.retryable());
|
||||
spanErrorRecorder.recordException(sanitizedPersistenceFailure(code), code.code());
|
||||
return ErrorResponseFactory.envelope(
|
||||
code, ClientSafeErrorMessages.forPersistence(code.category()), null);
|
||||
}
|
||||
|
||||
private static PersistenceFailureException sanitizedPersistenceFailure(ApiErrorCode code) {
|
||||
return new PersistenceFailureException(
|
||||
code, "persistence failure classified as " + code.code(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a pre-classified {@link DependencyFailureException}: its {@link
|
||||
* DependencyFailureException#errorCode()} sets the envelope code/status; the client message is a
|
||||
* per-code safe string and a {@code Retry-After} header is attached via {@link RetryAfterAdvisor}
|
||||
* when applicable. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(DependencyFailureException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleDependencyFailure(DependencyFailureException ex) {
|
||||
ApiErrorCode code = ex.errorCode();
|
||||
log.error(
|
||||
"dependency failure classified as {} (category={}, retryable={}, dependency={})",
|
||||
code.code(),
|
||||
code.category(),
|
||||
code.retryable(),
|
||||
ex.dependencyName(),
|
||||
ex);
|
||||
spanErrorRecorder.recordException(ex, code.code());
|
||||
String message = ClientSafeErrorMessages.forDependency(code);
|
||||
Envelope<Void> body = ErrorResponseFactory.body(code, message, null);
|
||||
ResponseEntity.BodyBuilder builder = ResponseEntity.status(code.httpStatus());
|
||||
RetryAfterAdvisor.retryAfterSeconds(code)
|
||||
.ifPresent(seconds -> builder.header(ApiHeaders.RETRY_AFTER, String.valueOf(seconds)));
|
||||
return builder.body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Envelope<Void>> handleUnknown(Exception ex, WebRequest req) {
|
||||
log.error("unhandled exception on {}", req.getDescription(false), ex);
|
||||
spanErrorRecorder.recordException(ex, OperationalError.INTERNAL_ERROR.code());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.INTERNAL_ERROR, "Internal server error", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
List<Map<String, Object>> fields =
|
||||
ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(ClientSafeValidationDetails::from)
|
||||
.toList();
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.VALIDATION_FAILED, "Request body failed validation", fields),
|
||||
HttpStatusCode.valueOf(OperationalError.VALIDATION_FAILED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMessageNotReadable(
|
||||
HttpMessageNotReadableException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
Map<String, Object> details =
|
||||
ex.getCause() != null ? Map.of("cause", ex.getCause().getClass().getSimpleName()) : null;
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.VALIDATION_FAILED, "Request body is malformed or unparsable", details),
|
||||
HttpStatusCode.valueOf(OperationalError.VALIDATION_FAILED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
|
||||
HttpRequestMethodNotSupportedException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 405 carries the `Allow` header listing the supported methods.
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
if (ex.getSupportedHttpMethods() != null) {
|
||||
responseHeaders.setAllow(new LinkedHashSet<>(ex.getSupportedHttpMethods()));
|
||||
}
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedHttpMethods() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMethods",
|
||||
ex.getSupportedHttpMethods().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.METHOD_NOT_ALLOWED,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.METHOD_NOT_ALLOWED),
|
||||
details),
|
||||
responseHeaders,
|
||||
HttpStatusCode.valueOf(OperationalError.METHOD_NOT_ALLOWED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(
|
||||
HttpMediaTypeNotSupportedException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 415: request body format unsupported.
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedMediaTypes() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMediaTypes",
|
||||
ex.getSupportedMediaTypes().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.UNSUPPORTED_MEDIA_TYPE,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.UNSUPPORTED_MEDIA_TYPE),
|
||||
details),
|
||||
HttpStatusCode.valueOf(OperationalError.UNSUPPORTED_MEDIA_TYPE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMaxUploadSizeExceededException(
|
||||
MaxUploadSizeExceededException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// An oversized request body classifies as 413 inside the envelope.
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.PAYLOAD_TOO_LARGE,
|
||||
"Request payload exceeds the maximum allowed size",
|
||||
null),
|
||||
HttpStatusCode.valueOf(OperationalError.PAYLOAD_TOO_LARGE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMediaTypeNotAcceptable(
|
||||
HttpMediaTypeNotAcceptableException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 406: no representation matches the Accept header.
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedMediaTypes() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMediaTypes",
|
||||
ex.getSupportedMediaTypes().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.NOT_ACCEPTABLE,
|
||||
"No acceptable representation for the requested Accept header",
|
||||
details),
|
||||
HttpStatusCode.valueOf(OperationalError.NOT_ACCEPTABLE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleNoHandlerFoundException(
|
||||
NoHandlerFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
|
||||
return routeNotFound();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleNoResourceFoundException(
|
||||
NoResourceFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
|
||||
return routeNotFound();
|
||||
}
|
||||
|
||||
private static ResponseEntity<Object> routeNotFound() {
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.ROUTE_NOT_FOUND,
|
||||
ClientSafeErrorMessages.forOperational(OperationalError.ROUTE_NOT_FOUND),
|
||||
null),
|
||||
HttpStatusCode.valueOf(OperationalError.ROUTE_NOT_FOUND.httpStatus()));
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
|
||||
import dev.caskeleton.application.fileserver.admin.FileserverAdminService;
|
||||
import dev.caskeleton.application.fileserver.admin.ForceDeleteCommand;
|
||||
import dev.caskeleton.application.fileserver.admin.IncompleteUploadView;
|
||||
import dev.caskeleton.application.fileserver.admin.OrphanObject;
|
||||
import dev.caskeleton.application.fileserver.admin.OrphanReconcileCommand;
|
||||
import dev.caskeleton.application.fileserver.admin.OrphanReconcileReport;
|
||||
import dev.caskeleton.application.fileserver.admin.RuntimeCapabilityReport;
|
||||
import dev.caskeleton.application.fileserver.admin.StorageHealthReport;
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.cleanup.CleanupBatchResult;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* The management plane, reachable only where it is explicitly enabled.
|
||||
*
|
||||
* <p>These routes live under {@code /internal/} and behind their own enablement property because
|
||||
* they are not part of the public API surface: a deployment that exposes the public application
|
||||
* port to the internet must be able to keep these off it entirely.
|
||||
*
|
||||
* <p>A reconcile without an explicit {@code dryRun=false} is always a dry run. That default lives
|
||||
* here as well as in the command, because the most dangerous request is the one that omits a field.
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(
|
||||
prefix = "app.fileserver-platform",
|
||||
name = {"enabled", "admin.enabled"},
|
||||
havingValue = "true")
|
||||
public class FileserverAdminController {
|
||||
|
||||
private static final int DEFAULT_PAGE = 100;
|
||||
|
||||
private final FileserverAdminService adminService;
|
||||
private final FileserverRequestContextFactory contextFactory;
|
||||
|
||||
public FileserverAdminController(
|
||||
FileserverAdminService adminService, FileserverRequestContextFactory contextFactory) {
|
||||
this.adminService = adminService;
|
||||
this.contextFactory = contextFactory;
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
path = "/internal/fileserver/storage-health",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public StorageHealthReport storageHealth() {
|
||||
return adminService.storageHealth(contextFactory.current());
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
path = "/internal/fileserver/capabilities",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public RuntimeCapabilityReport capabilities() {
|
||||
return adminService.capabilities(contextFactory.current());
|
||||
}
|
||||
|
||||
@GetMapping(path = "/internal/fileserver/orphans", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<OrphanObject> orphans(@RequestParam(name = "limit", defaultValue = "100") int limit) {
|
||||
return adminService.orphans(limit, contextFactory.current());
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/internal/fileserver/orphans:reconcile",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public OrphanReconcileReport reconcileOrphans(@RequestBody OrphanReconcileHttpRequest request) {
|
||||
return adminService.reconcileOrphans(toCommand(request), contextFactory.current());
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/internal/fileserver/files/{fileId}:reverify",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public UploadedFileResponse reverify(@PathVariable("fileId") String fileId) {
|
||||
return UploadedFileResponse.from(
|
||||
adminService.reverify(FileId.parse(fileId), contextFactory.current()));
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/internal/fileserver/files/{fileId}:force-delete",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<Void> forceDelete(
|
||||
@PathVariable("fileId") String fileId, @Valid @RequestBody ForceDeleteHttpRequest request) {
|
||||
adminService.forceDelete(
|
||||
new ForceDeleteCommand(FileId.parse(fileId), request.reasonCode()),
|
||||
contextFactory.current());
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
path = "/internal/fileserver/uploads/incomplete",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<IncompleteUploadView> incompleteUploads(
|
||||
@RequestParam(name = "limit", defaultValue = "100") int limit) {
|
||||
return adminService.incompleteUploads(limit, contextFactory.current());
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/internal/fileserver/uploads:cleanup",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public CleanupBatchResult cleanupUploads(
|
||||
@RequestParam(name = "maxItems", defaultValue = "100") int maxItems,
|
||||
@RequestParam(name = "maxBytes", defaultValue = "1073741824") long maxBytes) {
|
||||
return adminService.cleanupUploads(maxItems, maxBytes, contextFactory.current());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the wire request conservatively.
|
||||
*
|
||||
* <p>Every absent field resolves to the safe value: a dry run, the default page, and no
|
||||
* fingerprints. Nothing here can be omitted into a destructive default.
|
||||
*/
|
||||
private static OrphanReconcileCommand toCommand(OrphanReconcileHttpRequest request) {
|
||||
boolean dryRun = request.dryRun() == null || request.dryRun();
|
||||
int limit = request.limit() == null ? DEFAULT_PAGE : request.limit();
|
||||
if (dryRun) {
|
||||
return OrphanReconcileCommand.dryRun(limit);
|
||||
}
|
||||
return new OrphanReconcileCommand(
|
||||
false,
|
||||
limit,
|
||||
request.maxBytes() == null ? 1L << 30 : request.maxBytes(),
|
||||
request.expectedFingerprints() == null ? List.of() : request.expectedFingerprints(),
|
||||
request.reasonCode() == null ? "ORPHAN_APPLY" : request.reasonCode());
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** Wire form of a force delete; the reason is mandatory and is recorded in the audit trail. */
|
||||
public record ForceDeleteHttpRequest(@NotBlank @Size(min = 8, max = 200) String reasonCode) {}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wire form of a reconcile request.
|
||||
*
|
||||
* <p>{@code dryRun} is a wrapper type on purpose: an absent field must mean "dry run", and a
|
||||
* primitive would silently turn a missing value into {@code false}, which is an apply.
|
||||
*/
|
||||
public record OrphanReconcileHttpRequest(
|
||||
Boolean dryRun,
|
||||
Integer limit,
|
||||
Long maxBytes,
|
||||
List<String> expectedFingerprints,
|
||||
String reasonCode) {}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.config;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverException;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
|
||||
import dev.caskeleton.application.fileserver.api.error.TransferAdmissionRejectedException;
|
||||
import dev.caskeleton.application.fileserver.api.error.TransferTimeoutException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.core.task.TaskRejectedException;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* Admission control for blocking transfers.
|
||||
*
|
||||
* <p>The point of running transfers on their own bounded pool is not extra parallelism — the
|
||||
* servlet thread blocks on the result either way — it is that the pool plus its bounded queue caps
|
||||
* how many transfers can be in flight. Beyond that cap the request is rejected fast with a
|
||||
* retryable {@code 429} instead of pinning a container thread until the container itself runs out.
|
||||
*/
|
||||
public final class BlockingTransferExecutor {
|
||||
|
||||
private final ThreadPoolTaskExecutor executor;
|
||||
private final int awaitSeconds;
|
||||
|
||||
public BlockingTransferExecutor(ThreadPoolTaskExecutor executor, int awaitSeconds) {
|
||||
this.executor = executor;
|
||||
this.awaitSeconds = awaitSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@code work} on the transfer pool, translating saturation and timeout to design codes.
|
||||
*
|
||||
* <p>Submitted through {@link ThreadPoolTaskExecutor#submit} rather than {@code
|
||||
* CompletableFuture.supplyAsync}. That distinction is the whole timeout contract: {@code
|
||||
* CompletableFuture#cancel} ignores its {@code mayInterruptIfRunning} argument and never touches
|
||||
* the worker thread, so a timed-out transfer used to return {@code 504} to the client while the
|
||||
* worker kept streaming bytes into an abandoned response — holding a pool slot, a buffer and an
|
||||
* open channel for as long as the copy took. A real {@code Future} interrupts, and an interrupted
|
||||
* {@code FileChannel} closes itself, so the transfer actually stops.
|
||||
*/
|
||||
public <T> T call(Supplier<T> work) {
|
||||
Future<T> future;
|
||||
try {
|
||||
future = executor.submit(work::get);
|
||||
} catch (TaskRejectedException rejected) {
|
||||
throw new TransferAdmissionRejectedException(
|
||||
"transfer pool is saturated",
|
||||
rejected,
|
||||
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_ADMISSION_REJECTED, true));
|
||||
}
|
||||
try {
|
||||
return future.get(awaitSeconds, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
future.cancel(true);
|
||||
throw new TransferTimeoutException(
|
||||
"transfer was interrupted before completing",
|
||||
interrupted,
|
||||
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_TIMEOUT, true));
|
||||
} catch (TimeoutException timeout) {
|
||||
// Interrupting is the point: the caller is about to answer 504, and a worker still copying
|
||||
// into that response would keep a pool slot and an open channel for the rest of the transfer.
|
||||
future.cancel(true);
|
||||
throw new TransferTimeoutException(
|
||||
"transfer did not complete within the configured budget",
|
||||
timeout,
|
||||
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_TIMEOUT, true));
|
||||
} catch (ExecutionException failure) {
|
||||
throw rethrow(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps the worker failure.
|
||||
*
|
||||
* <p>A Fileserver failure keeps its own context — wrapping it in an execution exception here
|
||||
* would lose the code, the ambiguity flag, and the correct status.
|
||||
*/
|
||||
private static RuntimeException rethrow(ExecutionException failure) {
|
||||
Throwable cause = failure.getCause();
|
||||
if (cause instanceof FileserverException fileserverFailure) {
|
||||
return fileserverFailure;
|
||||
}
|
||||
if (cause instanceof RuntimeException runtime) {
|
||||
return runtime;
|
||||
}
|
||||
if (cause instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
return new IllegalStateException("transfer failed", cause);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.config;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.StorageNamespace;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Transport-side Fileserver settings.
|
||||
*
|
||||
* <p>These are the values a controller needs and that the application layer must not read for
|
||||
* itself: the default namespace for an unscoped request, the upload resource lifetime, and the
|
||||
* batch part ceiling.
|
||||
*/
|
||||
public record FileserverWebProperties(
|
||||
StorageNamespace defaultNamespace,
|
||||
Duration uploadTtl,
|
||||
int maxBatchParts,
|
||||
boolean contentLengthRequired) {
|
||||
|
||||
private static final int DESIGN_MAX_BATCH_PARTS = 16;
|
||||
|
||||
public FileserverWebProperties {
|
||||
Objects.requireNonNull(defaultNamespace, "defaultNamespace");
|
||||
Objects.requireNonNull(uploadTtl, "uploadTtl");
|
||||
if (uploadTtl.isNegative() || uploadTtl.isZero()) {
|
||||
throw new IllegalArgumentException("uploadTtl must be positive");
|
||||
}
|
||||
if (maxBatchParts < 1 || maxBatchParts > DESIGN_MAX_BATCH_PARTS) {
|
||||
throw new IllegalArgumentException("maxBatchParts must be between 1 and 16");
|
||||
}
|
||||
}
|
||||
|
||||
/** Design standard profile: 1 h upload lifetime, 16 batch parts, length optional. */
|
||||
public static FileserverWebProperties standard(StorageNamespace defaultNamespace) {
|
||||
return new FileserverWebProperties(
|
||||
defaultNamespace, Duration.ofHours(1), DESIGN_MAX_BATCH_PARTS, false);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.TaskDecorator;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* The bounded pool every blocking Fileserver transfer runs on.
|
||||
*
|
||||
* <p>The abort policy is the design decision, not a leftover default: silently running the transfer
|
||||
* on the caller's thread would defeat the bound, and an unbounded queue would trade a fast {@code
|
||||
* 429} for an eventual heap exhaustion. Rejection is translated into a retryable response by {@link
|
||||
* BlockingTransferExecutor}.
|
||||
*
|
||||
* <p>The pool is decorated so a transfer running on a worker thread still carries the caller's
|
||||
* correlation context; without it every transfer log line would be untraceable back to its request.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
public class MvcTransferExecutorConfiguration {
|
||||
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
public ThreadPoolTaskExecutor fileserverTransferExecutor(
|
||||
TransferExecutorProperties properties, TaskDecorator taskDecorator) {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setTaskDecorator(taskDecorator);
|
||||
executor.setCorePoolSize(properties.coreSize());
|
||||
executor.setMaxPoolSize(properties.maxSize());
|
||||
executor.setQueueCapacity(properties.queueCapacity());
|
||||
executor.setThreadNamePrefix("fs-transfer-");
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
executor.setAwaitTerminationSeconds(properties.awaitSeconds());
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
public BlockingTransferExecutor blockingTransferExecutor(
|
||||
ThreadPoolTaskExecutor fileserverTransferExecutor, TransferExecutorProperties properties) {
|
||||
return new BlockingTransferExecutor(fileserverTransferExecutor, properties.awaitSeconds());
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.config;
|
||||
|
||||
/**
|
||||
* Bounds on the blocking transfer pool.
|
||||
*
|
||||
* <p>Every value is a hard bound. An unbounded queue would turn a saturation event into a heap
|
||||
* exhaustion instead of a fast {@code 429}, which is why there is no "unlimited" option here.
|
||||
*/
|
||||
public record TransferExecutorProperties(
|
||||
int coreSize, int maxSize, int queueCapacity, int awaitSeconds) {
|
||||
|
||||
public TransferExecutorProperties {
|
||||
if (coreSize < 1 || maxSize < coreSize) {
|
||||
throw new IllegalArgumentException("maxSize must be at least coreSize and both positive");
|
||||
}
|
||||
if (queueCapacity < 1) {
|
||||
throw new IllegalArgumentException("queueCapacity must be positive");
|
||||
}
|
||||
if (awaitSeconds < 1) {
|
||||
throw new IllegalArgumentException("awaitSeconds must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
/** Design standard profile: core 8, max 32, queue 64. */
|
||||
public static TransferExecutorProperties standard() {
|
||||
return new TransferExecutorProperties(8, 32, 64, 300);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.controller;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.http.MvcConditionalRequestFactory;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.http.MvcDownloadResponseWriter;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.http.ZeroCopyEligibility;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.nginx.NginxDownloadStrategy;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
|
||||
import dev.caskeleton.application.fileserver.api.ByteRange;
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.security.RequestContext;
|
||||
import dev.caskeleton.application.fileserver.api.transfer.ConditionalRequest;
|
||||
import dev.caskeleton.application.fileserver.download.DownloadApplicationService;
|
||||
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
|
||||
import dev.caskeleton.application.fileserver.download.DownloadRequest;
|
||||
import dev.caskeleton.application.fileserver.download.ZeroCopyTransferResult;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ReadableByteChannel;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Metadata and content download endpoints.
|
||||
*
|
||||
* <p>GET and HEAD share one handler so their headers are identical by construction rather than by
|
||||
* convention. Content is opened only after the decision says a body is expected, so a {@code 304},
|
||||
* {@code 412}, or {@code 416} answer never reaches storage.
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
public class FileDownloadController {
|
||||
|
||||
private final DownloadApplicationService downloadService;
|
||||
private final MvcConditionalRequestFactory conditionalFactory;
|
||||
private final MvcDownloadResponseWriter responseWriter;
|
||||
private final FileserverRequestContextFactory contextFactory;
|
||||
private final BlockingTransferExecutor transferExecutor;
|
||||
private final NginxDownloadStrategy delegationStrategy;
|
||||
private final ZeroCopyEligibility zeroCopy;
|
||||
|
||||
/**
|
||||
* The only constructor.
|
||||
*
|
||||
* <p>There is deliberately no shorter overload defaulting {@code zeroCopy} to disabled. Two
|
||||
* constructors leave component scanning with no way to choose one, so the controller could not be
|
||||
* instantiated at all; and a caller that took the short form would silently lose the optimization
|
||||
* without saying so. Every construction site names its zero-copy policy.
|
||||
*/
|
||||
public FileDownloadController(
|
||||
DownloadApplicationService downloadService,
|
||||
MvcConditionalRequestFactory conditionalFactory,
|
||||
MvcDownloadResponseWriter responseWriter,
|
||||
FileserverRequestContextFactory contextFactory,
|
||||
BlockingTransferExecutor transferExecutor,
|
||||
NginxDownloadStrategy delegationStrategy,
|
||||
ZeroCopyEligibility zeroCopy) {
|
||||
this.downloadService = downloadService;
|
||||
this.conditionalFactory = conditionalFactory;
|
||||
this.responseWriter = responseWriter;
|
||||
this.contextFactory = contextFactory;
|
||||
this.transferExecutor = transferExecutor;
|
||||
this.delegationStrategy = delegationStrategy;
|
||||
this.zeroCopy = zeroCopy;
|
||||
}
|
||||
|
||||
@GetMapping(path = "/v1/files/{fileId}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public UploadedFileResponse describe(@PathVariable("fileId") String fileId) {
|
||||
return UploadedFileResponse.from(
|
||||
downloadService.describeFile(FileId.parse(fileId), contextFactory.current()));
|
||||
}
|
||||
|
||||
@RequestMapping(
|
||||
path = "/v1/files/{fileId}/content",
|
||||
method = {RequestMethod.GET, RequestMethod.HEAD})
|
||||
public void download(
|
||||
@PathVariable("fileId") String fileId,
|
||||
@RequestParam(name = "inline", required = false, defaultValue = "false") boolean inline,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
boolean headOnly = RequestMethod.HEAD.name().equalsIgnoreCase(request.getMethod());
|
||||
ConditionalRequest conditional = conditionalFactory.from(request, headOnly);
|
||||
RequestContext context = contextFactory.current();
|
||||
|
||||
DownloadDescriptor descriptor =
|
||||
downloadService.describe(
|
||||
new DownloadRequest(FileId.parse(fileId), conditional, inline), context);
|
||||
responseWriter.writeHeaders(descriptor, response);
|
||||
if (!descriptor.bodyExpected()) {
|
||||
return;
|
||||
}
|
||||
// Delegation is decided only after authorization and the READY gate, so the internal redirect
|
||||
// can only ever name content this caller was already allowed to read.
|
||||
if (delegationStrategy.shouldDelegate(descriptor)) {
|
||||
delegationStrategy.delegate(descriptor, response);
|
||||
return;
|
||||
}
|
||||
// `isSecure` is read here, on the container thread, because the request may be recycled before
|
||||
// the transfer task runs.
|
||||
boolean secure = request.isSecure();
|
||||
transferExecutor.call(() -> stream(descriptor, response, secure));
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the described bytes.
|
||||
*
|
||||
* <p>A full representation is treated as a single range so the read path has exactly one shape;
|
||||
* there is no separate "whole file" branch that could drift from the partial one.
|
||||
*
|
||||
* <p>A large plaintext response is offered to storage for a direct kernel transfer first. The
|
||||
* gateway may decline for any reason, and the streaming write below is then used unchanged — the
|
||||
* response is identical either way, which is what keeps this an optimization rather than a second
|
||||
* contract.
|
||||
*
|
||||
* <p>The fallback is taken only when nothing reached the socket. A transfer that moved some bytes
|
||||
* and then stopped has already committed the response, and streaming the representation on top of
|
||||
* it would send the prefix twice — a body that is longer than its own {@code Content-Length} and
|
||||
* matches neither the length nor the digest the client was promised. That case aborts instead.
|
||||
*/
|
||||
private Void stream(DownloadDescriptor descriptor, HttpServletResponse response, boolean secure) {
|
||||
if (!descriptor.isPartial() && descriptor.representation().length() == 0) {
|
||||
// A zero-length representation has no range at all. Clamping produced 0..0 — a one-byte
|
||||
// request over an empty object — which storage correctly refused as unsatisfiable, so a
|
||||
// legitimately empty file answered 416 instead of an empty 200.
|
||||
return null;
|
||||
}
|
||||
ByteRange range =
|
||||
descriptor.isPartial()
|
||||
? descriptor.singleRange()
|
||||
: ByteRange.entire(descriptor.representation().length());
|
||||
try {
|
||||
if (zeroCopy.isEligible(true, range.length(), secure)) {
|
||||
ZeroCopyTransferResult transfer =
|
||||
downloadService.transferContent(
|
||||
descriptor, range, Channels.newChannel(response.getOutputStream()));
|
||||
if (transfer.isComplete()) {
|
||||
return null;
|
||||
}
|
||||
if (!transfer.allowsFallback()) {
|
||||
throw new UncheckedIOException(
|
||||
new IOException(
|
||||
"direct transfer stopped after "
|
||||
+ transfer.transferredBytes()
|
||||
+ " bytes; the response is already committed and must not be re-sent"));
|
||||
}
|
||||
}
|
||||
try (ReadableByteChannel content = downloadService.openContent(descriptor, range)) {
|
||||
responseWriter.writeBody(content, response);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new UncheckedIOException(exception);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.controller;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.dto.BatchUploadItemResult;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.dto.BatchUploadResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.MultipartUploadRequestMapper;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.RawUploadRequestMapper;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.UploadIntent;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
|
||||
import dev.caskeleton.application.fileserver.api.FileState;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileTooLargeException;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverException;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
|
||||
import dev.caskeleton.application.fileserver.api.security.RequestContext;
|
||||
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
|
||||
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
|
||||
import dev.caskeleton.application.fileserver.upload.FileView;
|
||||
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
|
||||
import dev.caskeleton.application.fileserver.upload.SingleShotUploadService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ReadableByteChannel;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* Streaming upload endpoints.
|
||||
*
|
||||
* <p>Bytes are never materialized: the raw path wraps the servlet input stream and the multipart
|
||||
* path wraps each part's stream, so a 2 GiB upload costs a bounded buffer rather than 2 GiB of
|
||||
* heap. Every transfer goes through the bounded transfer pool, which turns overload into a fast
|
||||
* retryable rejection instead of container-thread exhaustion.
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
public class FileUploadController {
|
||||
|
||||
private final SingleShotUploadService uploadService;
|
||||
private final RawUploadRequestMapper rawMapper;
|
||||
private final MultipartUploadRequestMapper multipartMapper;
|
||||
private final FileserverRequestContextFactory contextFactory;
|
||||
private final BlockingTransferExecutor transferExecutor;
|
||||
private final FileserverWebProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
public FileUploadController(
|
||||
SingleShotUploadService uploadService,
|
||||
RawUploadRequestMapper rawMapper,
|
||||
MultipartUploadRequestMapper multipartMapper,
|
||||
FileserverRequestContextFactory contextFactory,
|
||||
BlockingTransferExecutor transferExecutor,
|
||||
FileserverWebProperties properties,
|
||||
Clock clock) {
|
||||
this.uploadService = uploadService;
|
||||
this.rawMapper = rawMapper;
|
||||
this.multipartMapper = multipartMapper;
|
||||
this.contextFactory = contextFactory;
|
||||
this.transferExecutor = transferExecutor;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** Raw streaming upload; the whole request body is the file. */
|
||||
// The channel wraps the servlet request body. Closing it would close the container's input
|
||||
// stream, which the container owns and reuses for keep-alive; the upload must read the body
|
||||
// and leave the stream alone.
|
||||
@SuppressWarnings("resource")
|
||||
@PostMapping(path = "/v1/files:raw", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<UploadedFileResponse> uploadRaw(HttpServletRequest request)
|
||||
throws IOException {
|
||||
UploadIntent intent = rawMapper.map(request);
|
||||
RequestContext context = contextFactory.current();
|
||||
InputStream body = request.getInputStream();
|
||||
FileView view =
|
||||
transferExecutor.call(
|
||||
() ->
|
||||
upload(
|
||||
intent,
|
||||
Channels.newChannel(body),
|
||||
declaredLength(intent),
|
||||
context,
|
||||
UploadProtocol.RAW));
|
||||
return created(view);
|
||||
}
|
||||
|
||||
/** Multipart single upload. */
|
||||
@PostMapping(
|
||||
path = "/v1/files",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<UploadedFileResponse> uploadMultipart(
|
||||
@RequestParam("file") MultipartFile file) throws IOException {
|
||||
UploadIntent intent = multipartMapper.map(file);
|
||||
RequestContext context = contextFactory.current();
|
||||
// Closed on every path, including a rejection thrown inside the transfer. A multipart part is
|
||||
// backed by a temporary file or a buffer the container only releases when the stream is closed.
|
||||
try (InputStream body = file.getInputStream()) {
|
||||
ReadableByteChannel content = Channels.newChannel(body);
|
||||
FileView view =
|
||||
transferExecutor.call(
|
||||
() -> upload(intent, content, file.getSize(), context, UploadProtocol.MULTIPART));
|
||||
return created(view);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded multi-file upload.
|
||||
*
|
||||
* <p>The batch is explicitly non-atomic: each part is an independent file, a failure never rolls
|
||||
* back a sibling that already succeeded, and the response is always {@code 200} carrying the
|
||||
* ordered per-part outcome.
|
||||
*/
|
||||
@PostMapping(
|
||||
path = "/v1/files:batch",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<BatchUploadResponse> uploadBatch(
|
||||
@RequestParam("files") List<MultipartFile> files) {
|
||||
requirePartCountWithinPolicy(files.size());
|
||||
RequestContext context = contextFactory.current();
|
||||
List<BatchUploadItemResult> results = new ArrayList<>(files.size());
|
||||
for (int index = 0; index < files.size(); index++) {
|
||||
results.add(uploadPart(files.get(index), index, context));
|
||||
}
|
||||
return ResponseEntity.ok(new BatchUploadResponse(results));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one batch part.
|
||||
*
|
||||
* <p>A part failure is converted to a per-part problem instead of aborting the request, which is
|
||||
* what makes the endpoint's non-atomic contract observable rather than merely documented.
|
||||
*/
|
||||
private BatchUploadItemResult uploadPart(MultipartFile part, int index, RequestContext context) {
|
||||
String clientPartId = partId(part, index);
|
||||
try (InputStream body = part.getInputStream()) {
|
||||
UploadIntent intent = multipartMapper.map(part);
|
||||
ReadableByteChannel content = Channels.newChannel(body);
|
||||
FileView view =
|
||||
transferExecutor.call(
|
||||
() -> upload(intent, content, part.getSize(), context, UploadProtocol.BATCH));
|
||||
return BatchUploadItemResult.accepted(clientPartId, view);
|
||||
} catch (FileserverException failure) {
|
||||
return BatchUploadItemResult.rejected(clientPartId, failure.code());
|
||||
} catch (IOException failure) {
|
||||
return BatchUploadItemResult.rejected(clientPartId, FileserverErrorCode.STORAGE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the protocol the request actually used.
|
||||
*
|
||||
* <p>Every endpoint previously persisted {@code RAW}. The recorded protocol is what a resume, an
|
||||
* audit, and a reconciliation read to decide how an upload was produced, so labelling a batch
|
||||
* part as a raw upload makes all three describe something that never happened.
|
||||
*/
|
||||
private FileView upload(
|
||||
UploadIntent intent,
|
||||
ReadableByteChannel content,
|
||||
long contentLength,
|
||||
RequestContext context,
|
||||
UploadProtocol protocol) {
|
||||
CreateUploadRequest request =
|
||||
new CreateUploadRequest(
|
||||
properties.defaultNamespace(),
|
||||
intent.originalFilename(),
|
||||
intent.claimedMediaType(),
|
||||
intent.declaredLength(),
|
||||
intent.expectedSha256(),
|
||||
protocol,
|
||||
clock.instant().plus(properties.uploadTtl()));
|
||||
return uploadService.upload(
|
||||
request,
|
||||
content,
|
||||
contentLength,
|
||||
new FinalizeUploadRequest(intent.expectedSha256(), false),
|
||||
context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the completed upload.
|
||||
*
|
||||
* <p>READY is a finished object, so it answers {@code 201}; anything still under verification is
|
||||
* {@code 202} with no public content behind it yet.
|
||||
*/
|
||||
private static ResponseEntity<UploadedFileResponse> created(FileView view) {
|
||||
HttpStatus status = view.state() == FileState.READY ? HttpStatus.CREATED : HttpStatus.ACCEPTED;
|
||||
return ResponseEntity.status(status)
|
||||
.location(URI.create("/v1/files/" + view.fileId().canonicalText()))
|
||||
.body(UploadedFileResponse.from(view));
|
||||
}
|
||||
|
||||
private void requirePartCountWithinPolicy(int partCount) {
|
||||
if (partCount > properties.maxBatchParts()) {
|
||||
throw new FileTooLargeException(
|
||||
"batch exceeds the configured maximum part count",
|
||||
FileserverFailureContext.of(FileserverErrorCode.FILE_TOO_LARGE, false));
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable per-part identity so a caller can correlate a result with what it sent. */
|
||||
private static String partId(MultipartFile part, int index) {
|
||||
String name = part.getOriginalFilename();
|
||||
return name == null || name.isBlank() ? String.valueOf(index) : name;
|
||||
}
|
||||
|
||||
private static long declaredLength(UploadIntent intent) {
|
||||
return intent.declaredLength().isPresent() ? intent.declaredLength().getAsLong() : -1;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
|
||||
|
||||
/**
|
||||
* The draft-12 header and media-type vocabulary.
|
||||
*
|
||||
* <p>Deliberately separate from the tus vocabulary even where the names coincide: sharing the
|
||||
* constants would couple a Stable protocol to an unratified one, so a draft revision could silently
|
||||
* change tus behaviour.
|
||||
*/
|
||||
public final class Draft12Headers {
|
||||
|
||||
public static final String UPLOAD_OFFSET = "Upload-Offset";
|
||||
public static final String UPLOAD_COMPLETE = "Upload-Complete";
|
||||
public static final String UPLOAD_LIMIT = "Upload-Limit";
|
||||
|
||||
/** Media type a draft-12 append carries. */
|
||||
public static final String PARTIAL_UPLOAD = "application/partial-upload";
|
||||
|
||||
private Draft12Headers() {}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
|
||||
|
||||
/**
|
||||
* The draft's offset-mismatch problem document.
|
||||
*
|
||||
* <p>It reports both offsets so the client can resume without a second round trip. This shape is
|
||||
* the draft's own and is intentionally not the Fileserver problem document: a draft revision must
|
||||
* be able to change it without touching the Stable contract.
|
||||
*/
|
||||
public record Draft12OffsetProblem(
|
||||
String type, String title, int status, long expectedOffset, long providedOffset) {
|
||||
|
||||
public static final String TYPE =
|
||||
"https://iana.org/assignments/http-problem-types#mismatching-upload-offset";
|
||||
|
||||
public static Draft12OffsetProblem of(long expectedOffset, long providedOffset) {
|
||||
return new Draft12OffsetProblem(
|
||||
TYPE, "Mismatching upload offset", 409, expectedOffset, providedOffset);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Settings for the experimental draft-12 protocol.
|
||||
*
|
||||
* <p>Disabled by default. An unratified protocol that shipped enabled would make every deployment
|
||||
* carry a surface whose contract can change without notice.
|
||||
*/
|
||||
public record Draft12Properties(
|
||||
boolean enabled, long maxSize, Duration uploadTtl, boolean interimResponsesSupported) {
|
||||
|
||||
public Draft12Properties {
|
||||
Objects.requireNonNull(uploadTtl, "uploadTtl");
|
||||
if (maxSize <= 0) {
|
||||
throw new IllegalArgumentException("maxSize must be positive");
|
||||
}
|
||||
if (uploadTtl.isNegative() || uploadTtl.isZero()) {
|
||||
throw new IllegalArgumentException("uploadTtl must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
/** Disabled profile, which is the shipped default. */
|
||||
public static Draft12Properties disabled() {
|
||||
return new Draft12Properties(false, 100L * 1024 * 1024, Duration.ofHours(1), false);
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import dev.caskeleton.application.fileserver.api.error.MalformedRequestException;
|
||||
import dev.caskeleton.application.fileserver.api.error.UploadOffsetMismatchException;
|
||||
import dev.caskeleton.application.fileserver.api.security.RequestContext;
|
||||
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
|
||||
import dev.caskeleton.application.fileserver.upload.AppendUploadResult;
|
||||
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
|
||||
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
|
||||
import dev.caskeleton.application.fileserver.upload.FinalizeUploadService;
|
||||
import dev.caskeleton.application.fileserver.upload.UploadApplicationService;
|
||||
import dev.caskeleton.application.fileserver.upload.UploadSessionView;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.channels.Channels;
|
||||
import java.time.Clock;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* HTTP resumable uploads, draft-12. Experimental.
|
||||
*
|
||||
* <p>It shares no path, no header constant, and no response type with the tus adapter. That
|
||||
* separation is the point: the draft is unratified, and a future revision must be able to change
|
||||
* this surface without touching a Stable protocol that clients already depend on.
|
||||
*
|
||||
* <p>Only the researched part of the draft is implemented — {@code Upload-Offset}, {@code
|
||||
* Upload-Complete}, the partial-upload media type, and the offset-mismatch problem type. Nothing is
|
||||
* guessed from a later revision.
|
||||
*/
|
||||
@RestController
|
||||
@ExperimentalApi(specification = "draft-ietf-httpbis-resumable-upload-12")
|
||||
@ConditionalOnProperty(
|
||||
prefix = "app.fileserver-platform",
|
||||
name = {"enabled", "httpbis-draft12.enabled"},
|
||||
havingValue = "true")
|
||||
public class Draft12UploadController {
|
||||
|
||||
private static final String DRAFT_PATH = "/v1/experimental/draft12/uploads";
|
||||
|
||||
private final UploadApplicationService uploadService;
|
||||
private final FinalizeUploadService finalizeService;
|
||||
private final FileserverRequestContextFactory contextFactory;
|
||||
private final BlockingTransferExecutor transferExecutor;
|
||||
private final FileserverWebProperties webProperties;
|
||||
private final Draft12Properties draftProperties;
|
||||
private final Clock clock;
|
||||
|
||||
public Draft12UploadController(
|
||||
UploadApplicationService uploadService,
|
||||
FinalizeUploadService finalizeService,
|
||||
FileserverRequestContextFactory contextFactory,
|
||||
BlockingTransferExecutor transferExecutor,
|
||||
FileserverWebProperties webProperties,
|
||||
Draft12Properties draftProperties,
|
||||
Clock clock) {
|
||||
this.uploadService = uploadService;
|
||||
this.finalizeService = finalizeService;
|
||||
this.contextFactory = contextFactory;
|
||||
this.transferExecutor = transferExecutor;
|
||||
this.webProperties = webProperties;
|
||||
this.draftProperties = draftProperties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@PostMapping(DRAFT_PATH)
|
||||
public ResponseEntity<Void> create(HttpServletRequest request) {
|
||||
UploadSessionView created =
|
||||
uploadService.create(
|
||||
new CreateUploadRequest(
|
||||
webProperties.defaultNamespace(),
|
||||
filename(request),
|
||||
Optional.empty(),
|
||||
declaredLength(request),
|
||||
Optional.empty(),
|
||||
UploadProtocol.HTTPBIS_DRAFT12,
|
||||
clock.instant().plus(draftProperties.uploadTtl())),
|
||||
contextFactory.current());
|
||||
|
||||
return ResponseEntity.created(URI.create(DRAFT_PATH + "/" + created.uploadId().canonicalText()))
|
||||
.header(Draft12Headers.UPLOAD_OFFSET, "0")
|
||||
.header(Draft12Headers.UPLOAD_LIMIT, "max-size=" + draftProperties.maxSize())
|
||||
.build();
|
||||
}
|
||||
|
||||
@PatchMapping(path = DRAFT_PATH + "/{uploadId}", consumes = Draft12Headers.PARTIAL_UPLOAD)
|
||||
// The channel wraps the servlet request body. Closing it would close the container's input
|
||||
// stream, which the container owns and reuses for keep-alive; the upload must read the body
|
||||
// and leave the stream alone.
|
||||
@SuppressWarnings("resource")
|
||||
public ResponseEntity<Void> append(
|
||||
@PathVariable("uploadId") String uploadId, HttpServletRequest request) throws IOException {
|
||||
UploadId id = UploadId.parse(uploadId);
|
||||
long expectedOffset = requiredOffset(request);
|
||||
boolean complete = isComplete(request);
|
||||
RequestContext context = contextFactory.current();
|
||||
InputStream body = request.getInputStream();
|
||||
long declared = request.getContentLengthLong();
|
||||
|
||||
AppendUploadResult appended =
|
||||
transferExecutor.call(
|
||||
() ->
|
||||
uploadService.append(
|
||||
id, expectedOffset, Channels.newChannel(body), declared, context));
|
||||
if (complete) {
|
||||
finalizeService.finalizeUpload(id, FinalizeUploadRequest.synchronousWithoutDigest(), context);
|
||||
}
|
||||
return ResponseEntity.noContent()
|
||||
.header(Draft12Headers.UPLOAD_OFFSET, String.valueOf(appended.committedOffset()))
|
||||
.header(Draft12Headers.UPLOAD_COMPLETE, complete ? "?1" : "?0")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the draft's own offset-mismatch problem document.
|
||||
*
|
||||
* <p>The draft defines a specific problem type carrying both offsets; mapping this through the
|
||||
* shared Fileserver problem handler would answer the right status with the wrong body.
|
||||
*/
|
||||
@ExceptionHandler(UploadOffsetMismatchException.class)
|
||||
public ResponseEntity<Draft12OffsetProblem> offsetMismatch(
|
||||
UploadOffsetMismatchException failure) {
|
||||
return ResponseEntity.status(409)
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.header(Draft12Headers.UPLOAD_OFFSET, String.valueOf(failure.currentOffset()))
|
||||
.body(Draft12OffsetProblem.of(failure.currentOffset(), failure.expectedOffset()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the structured-field boolean {@code Upload-Complete}.
|
||||
*
|
||||
* <p>An absent header means the upload continues; only the explicit {@code ?1} form completes it,
|
||||
* so a truncated request can never publish a partial object.
|
||||
*/
|
||||
private static boolean isComplete(HttpServletRequest request) {
|
||||
return "?1".equals(request.getHeader(Draft12Headers.UPLOAD_COMPLETE));
|
||||
}
|
||||
|
||||
private static long requiredOffset(HttpServletRequest request) {
|
||||
String header = request.getHeader(Draft12Headers.UPLOAD_OFFSET);
|
||||
if (header == null || header.isBlank()) {
|
||||
throw MalformedRequestException.of("draft-12 append requires Upload-Offset");
|
||||
}
|
||||
try {
|
||||
long value = Long.parseLong(header.trim());
|
||||
if (value < 0) {
|
||||
throw new NumberFormatException("negative");
|
||||
}
|
||||
return value;
|
||||
} catch (NumberFormatException malformed) {
|
||||
throw MalformedRequestException.of("Upload-Offset is not a non-negative integer");
|
||||
}
|
||||
}
|
||||
|
||||
private static OptionalLong declaredLength(HttpServletRequest request) {
|
||||
long length = request.getContentLengthLong();
|
||||
return length < 0 ? OptionalLong.empty() : OptionalLong.of(length);
|
||||
}
|
||||
|
||||
private static String filename(HttpServletRequest request) {
|
||||
String header = request.getHeader("X-Filename");
|
||||
return header == null || header.isBlank() ? "upload.bin" : header;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Marks a type that implements an unratified specification.
|
||||
*
|
||||
* <p>An experimental protocol changes between drafts, so anything marked here may break on a
|
||||
* specification revision even though this project's own contract did not change. The marker exists
|
||||
* so that is visible in code review rather than discovered in production.
|
||||
*/
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ExperimentalApi {
|
||||
|
||||
/** The exact draft this type implements. */
|
||||
String specification();
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
|
||||
|
||||
/**
|
||||
* Failure of one batch part, in the same vocabulary the single-file endpoints use.
|
||||
*
|
||||
* <p>Only the stable code and its problem URN are exposed; the server-side message never crosses
|
||||
* this boundary.
|
||||
*/
|
||||
public record BatchUploadItemProblem(String code, String type, int status) {
|
||||
|
||||
public static BatchUploadItemProblem of(FileserverErrorCode code) {
|
||||
return new BatchUploadItemProblem(code.name(), code.problemType(), code.httpStatus());
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
|
||||
import dev.caskeleton.application.fileserver.upload.FileView;
|
||||
|
||||
/**
|
||||
* Result of one batch part.
|
||||
*
|
||||
* <p>A batch is explicitly non-atomic, so each part reports its own outcome and a failure never
|
||||
* rolls back a sibling that already succeeded.
|
||||
*/
|
||||
public record BatchUploadItemResult(
|
||||
String clientPartId, String status, String fileId, BatchUploadItemProblem problem) {
|
||||
|
||||
public static BatchUploadItemResult accepted(String clientPartId, FileView view) {
|
||||
return new BatchUploadItemResult(
|
||||
clientPartId, view.state().name(), view.fileId().canonicalText(), null);
|
||||
}
|
||||
|
||||
public static BatchUploadItemResult rejected(String clientPartId, FileserverErrorCode code) {
|
||||
return new BatchUploadItemResult(
|
||||
clientPartId, "REJECTED", null, BatchUploadItemProblem.of(code));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ordered per-part outcome of one batch upload.
|
||||
*
|
||||
* <p>The endpoint answers {@code 200} even when some parts failed: the batch has no request-wide
|
||||
* atomicity, and pretending otherwise with a single status would hide the parts that succeeded.
|
||||
*/
|
||||
public record BatchUploadResponse(List<BatchUploadItemResult> results) {
|
||||
|
||||
public BatchUploadResponse {
|
||||
results = List.copyOf(results);
|
||||
}
|
||||
|
||||
/** True when every part succeeded, which the envelope layer reports as a plain success. */
|
||||
public boolean fullySucceeded() {
|
||||
return results.stream().allMatch(result -> result.problem() == null);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/**
|
||||
* Target of a copy or move.
|
||||
*
|
||||
* <p>The namespace pattern is enforced at the boundary as syntax; the value object enforces it
|
||||
* again as an invariant. {@code filename} is untrusted display data that the application layer
|
||||
* sanitizes — it is never used to build a physical key.
|
||||
*/
|
||||
public record RelocateFileRequest(
|
||||
@NotBlank @Pattern(regexp = "[a-z][a-z0-9-]{1,62}") String namespace, String filename) {}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
|
||||
|
||||
import dev.caskeleton.application.fileserver.upload.FileView;
|
||||
|
||||
/**
|
||||
* Wire projection of one completed upload.
|
||||
*
|
||||
* <p>It carries only what the public descriptor already exposes; no content key, physical path, or
|
||||
* container temporary path ever appears here.
|
||||
*/
|
||||
public record UploadedFileResponse(
|
||||
String fileId,
|
||||
String state,
|
||||
String filename,
|
||||
String mediaType,
|
||||
long size,
|
||||
String sha256,
|
||||
String etag) {
|
||||
|
||||
public static UploadedFileResponse from(FileView view) {
|
||||
return new UploadedFileResponse(
|
||||
view.fileId().canonicalText(),
|
||||
view.state().name(),
|
||||
view.descriptor().originalFilename(),
|
||||
view.descriptor().mediaType(),
|
||||
view.descriptor().size(),
|
||||
view.descriptor().sha256(),
|
||||
view.descriptor().strongEtag());
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.http;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.transfer.ConditionalRequest;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
||||
/**
|
||||
* Translates servlet headers into the transport-neutral conditional request.
|
||||
*
|
||||
* <p>Doing the translation here — and only here — is what lets MVC, WebFlux, and the delegation
|
||||
* path share one decision implementation instead of each re-deriving the precedence rules.
|
||||
*
|
||||
* <p>An unparseable HTTP-date is treated as absent rather than as a failure, which is what RFC 9110
|
||||
* requires: a malformed conditional header must be ignored, not turned into an error.
|
||||
*/
|
||||
public final class MvcConditionalRequestFactory {
|
||||
|
||||
public ConditionalRequest from(HttpServletRequest request, boolean headOnly) {
|
||||
return new ConditionalRequest(
|
||||
header(request, HttpHeaders.IF_MATCH),
|
||||
header(request, HttpHeaders.IF_NONE_MATCH),
|
||||
date(request, HttpHeaders.IF_MODIFIED_SINCE),
|
||||
date(request, HttpHeaders.IF_UNMODIFIED_SINCE),
|
||||
header(request, HttpHeaders.IF_RANGE),
|
||||
header(request, HttpHeaders.RANGE),
|
||||
headOnly);
|
||||
}
|
||||
|
||||
private static Optional<String> header(HttpServletRequest request, String name) {
|
||||
String value = request.getHeader(name);
|
||||
return value == null || value.isBlank() ? Optional.empty() : Optional.of(value);
|
||||
}
|
||||
|
||||
private static Optional<Instant> date(HttpServletRequest request, String name) {
|
||||
Optional<String> raw = header(request, name);
|
||||
if (raw.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(
|
||||
ZonedDateTime.parse(raw.get(), DateTimeFormatter.RFC_1123_DATE_TIME).toInstant());
|
||||
} catch (DateTimeParseException malformed) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.http;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ByteRange;
|
||||
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.ReadableByteChannel;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
||||
/**
|
||||
* Writes one download decision onto a servlet response.
|
||||
*
|
||||
* <p>GET and HEAD render exactly the same header set; only the body differs. That is deliberate: a
|
||||
* HEAD whose {@code Content-Length} or {@code ETag} disagreed with the GET would make range
|
||||
* resumption and cache revalidation unreliable.
|
||||
*
|
||||
* <p>The body is streamed through one bounded buffer, so a large object costs a fixed amount of
|
||||
* heap rather than its own size.
|
||||
*/
|
||||
public final class MvcDownloadResponseWriter {
|
||||
|
||||
/** Header that stops a browser re-sniffing a declared media type. */
|
||||
public static final String CONTENT_TYPE_OPTIONS = "X-Content-Type-Options";
|
||||
|
||||
private static final int BUFFER_BYTES = 64 * 1024;
|
||||
private static final DateTimeFormatter HTTP_DATE =
|
||||
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
|
||||
|
||||
/** Writes status and headers; {@code 304} deliberately carries no representation metadata. */
|
||||
public void writeHeaders(DownloadDescriptor descriptor, HttpServletResponse response) {
|
||||
response.setStatus(descriptor.status());
|
||||
response.setHeader(HttpHeaders.ETAG, descriptor.representation().strongEtag());
|
||||
response.setHeader(
|
||||
HttpHeaders.LAST_MODIFIED,
|
||||
HTTP_DATE.format(
|
||||
ZonedDateTime.ofInstant(descriptor.representation().lastModified(), ZoneOffset.UTC)));
|
||||
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
|
||||
response.setHeader(HttpHeaders.CACHE_CONTROL, descriptor.cacheControl());
|
||||
// Uploaded content never gets to describe itself: without nosniff a browser may re-interpret a
|
||||
// declared octet-stream as HTML and execute it from this origin.
|
||||
response.setHeader(CONTENT_TYPE_OPTIONS, "nosniff");
|
||||
if (descriptor.status() == HttpServletResponse.SC_NOT_MODIFIED) {
|
||||
return;
|
||||
}
|
||||
response.setHeader(HttpHeaders.CONTENT_TYPE, descriptor.representation().mediaType());
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, descriptor.contentDisposition());
|
||||
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(payloadLength(descriptor)));
|
||||
if (descriptor.isPartial()) {
|
||||
ByteRange range = descriptor.singleRange();
|
||||
response.setHeader(
|
||||
HttpHeaders.CONTENT_RANGE,
|
||||
"bytes "
|
||||
+ range.startInclusive()
|
||||
+ "-"
|
||||
+ range.endInclusive()
|
||||
+ "/"
|
||||
+ descriptor.representation().length());
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams {@code content} into the response through a single bounded buffer. */
|
||||
public void writeBody(ReadableByteChannel content, HttpServletResponse response)
|
||||
throws IOException {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(BUFFER_BYTES);
|
||||
try (ReadableByteChannel source = content) {
|
||||
OutputStream target = response.getOutputStream();
|
||||
while (source.read(buffer) >= 0) {
|
||||
buffer.flip();
|
||||
target.write(buffer.array(), buffer.arrayOffset(), buffer.limit());
|
||||
buffer.clear();
|
||||
}
|
||||
target.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Length the body would carry.
|
||||
*
|
||||
* <p>A HEAD reports the length it would have sent, so the header set matches the GET exactly even
|
||||
* though no bytes follow.
|
||||
*/
|
||||
private static long payloadLength(DownloadDescriptor descriptor) {
|
||||
if (descriptor.isPartial()) {
|
||||
return descriptor.singleRange().length();
|
||||
}
|
||||
return descriptor.representation().length();
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.http;
|
||||
|
||||
import org.springframework.http.ZeroCopyHttpOutputMessage;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
|
||||
/**
|
||||
* Decides whether a response may be written with a kernel-level file transfer.
|
||||
*
|
||||
* <p>Zero copy is an optimization and never a contract change, so it is taken only when every
|
||||
* precondition holds at once: the response implementation supports it, the body needs no
|
||||
* transformation, and the connection is not encrypted in user space (TLS has to see the plaintext,
|
||||
* so a {@code sendfile} would bypass the very layer that must transform it).
|
||||
*/
|
||||
public final class ZeroCopyEligibility {
|
||||
|
||||
private final boolean enabled;
|
||||
private final long minimumBytes;
|
||||
|
||||
public ZeroCopyEligibility(boolean enabled, long minimumBytes) {
|
||||
if (minimumBytes < 0) {
|
||||
throw new IllegalArgumentException("minimumBytes must not be negative");
|
||||
}
|
||||
this.enabled = enabled;
|
||||
this.minimumBytes = minimumBytes;
|
||||
}
|
||||
|
||||
/** Design default: enabled above 16 MiB. */
|
||||
public static ZeroCopyEligibility standard() {
|
||||
return new ZeroCopyEligibility(true, 16L * 1024 * 1024);
|
||||
}
|
||||
|
||||
public static ZeroCopyEligibility disabled() {
|
||||
return new ZeroCopyEligibility(false, Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
public boolean isEligible(ServerHttpResponse response, long payloadBytes, boolean secure) {
|
||||
return isEligible(supportsZeroCopy(response), payloadBytes, secure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides eligibility from already-resolved facts.
|
||||
*
|
||||
* <p>Separating the capability probe from the policy keeps the policy testable without
|
||||
* constructing a server response, and keeps the probe in exactly one place.
|
||||
*/
|
||||
public boolean isEligible(boolean responseCapable, long payloadBytes, boolean secure) {
|
||||
return enabled && responseCapable && !secure && payloadBytes >= minimumBytes;
|
||||
}
|
||||
|
||||
/** True when the response implementation can hand a file straight to the kernel. */
|
||||
public static boolean supportsZeroCopy(ServerHttpResponse response) {
|
||||
return response instanceof ZeroCopyHttpOutputMessage;
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.lifecycle;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.dto.RelocateFileRequest;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.StorageNamespace;
|
||||
import dev.caskeleton.application.fileserver.lifecycle.CopyFileCommand;
|
||||
import dev.caskeleton.application.fileserver.lifecycle.DeleteOutcome;
|
||||
import dev.caskeleton.application.fileserver.lifecycle.FileLifecycleService;
|
||||
import dev.caskeleton.application.fileserver.upload.FileView;
|
||||
import jakarta.validation.Valid;
|
||||
import java.net.URI;
|
||||
import java.util.Optional;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Delete, copy, and move endpoints.
|
||||
*
|
||||
* <p>The status codes carry meaning that the client needs. A delete that still has physical content
|
||||
* to reclaim answers {@code 202}, not {@code 204}: the file is already unreadable, but the
|
||||
* operation is not finished, and a caller waiting for storage to be freed must be able to tell the
|
||||
* difference.
|
||||
*
|
||||
* <p>These routes live outside the {@code controller} package because AIP-136's colon verb is
|
||||
* applied to a path variable here — the copy and move paths append the verb to the file-id segment
|
||||
* — which the repository's AIP-122 segment rule does not model. The design fixes those paths, so
|
||||
* the code moves rather than the contract.
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
public class FileLifecycleController {
|
||||
|
||||
private final FileLifecycleService lifecycleService;
|
||||
private final FileserverRequestContextFactory contextFactory;
|
||||
|
||||
public FileLifecycleController(
|
||||
FileLifecycleService lifecycleService, FileserverRequestContextFactory contextFactory) {
|
||||
this.lifecycleService = lifecycleService;
|
||||
this.contextFactory = contextFactory;
|
||||
}
|
||||
|
||||
@DeleteMapping("/v1/files/{fileId}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable("fileId") String fileId,
|
||||
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch) {
|
||||
DeleteOutcome outcome =
|
||||
lifecycleService.delete(FileId.parse(fileId), optional(ifMatch), contextFactory.current());
|
||||
return outcome.physicalCleanupScheduled()
|
||||
? ResponseEntity.accepted().build()
|
||||
: ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/v1/files/{fileId}:copy",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<UploadedFileResponse> copy(
|
||||
@PathVariable("fileId") String fileId,
|
||||
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch,
|
||||
@Valid @RequestBody RelocateFileRequest request) {
|
||||
FileView copied =
|
||||
lifecycleService.copy(
|
||||
new CopyFileCommand(
|
||||
FileId.parse(fileId),
|
||||
StorageNamespace.of(request.namespace()),
|
||||
Optional.ofNullable(request.filename()),
|
||||
optional(ifMatch)),
|
||||
contextFactory.current());
|
||||
return ResponseEntity.accepted()
|
||||
.location(URI.create("/v1/files/" + copied.fileId().canonicalText()))
|
||||
.body(UploadedFileResponse.from(copied));
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/v1/files/{fileId}:move",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public UploadedFileResponse move(
|
||||
@PathVariable("fileId") String fileId,
|
||||
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch,
|
||||
@Valid @RequestBody RelocateFileRequest request) {
|
||||
return UploadedFileResponse.from(
|
||||
lifecycleService.move(
|
||||
FileId.parse(fileId),
|
||||
StorageNamespace.of(request.namespace()),
|
||||
optional(ifMatch),
|
||||
contextFactory.current()));
|
||||
}
|
||||
|
||||
private static Optional<String> optional(String header) {
|
||||
return header == null || header.isBlank() ? Optional.empty() : Optional.of(header);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* Reads one multipart part's intent.
|
||||
*
|
||||
* <p>The part is never materialized here: {@code getBytes()} would pull the whole file into the
|
||||
* heap, which is exactly the failure mode the streaming design exists to avoid. Only the part's
|
||||
* declared metadata is read.
|
||||
*/
|
||||
public final class MultipartUploadRequestMapper {
|
||||
|
||||
private static final String FALLBACK_FILENAME = "upload.bin";
|
||||
|
||||
public UploadIntent map(MultipartFile part) {
|
||||
return new UploadIntent(
|
||||
filename(part), claimedMediaType(part), OptionalLong.of(part.getSize()), Optional.empty());
|
||||
}
|
||||
|
||||
private static String filename(MultipartFile part) {
|
||||
String submitted = part.getOriginalFilename();
|
||||
return submitted == null || submitted.isBlank() ? FALLBACK_FILENAME : submitted;
|
||||
}
|
||||
|
||||
private static Optional<String> claimedMediaType(MultipartFile part) {
|
||||
String contentType = part.getContentType();
|
||||
return contentType == null || contentType.isBlank()
|
||||
? Optional.empty()
|
||||
: Optional.of(contentType);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
|
||||
import dev.caskeleton.application.fileserver.api.error.UnsupportedMediaTypeException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Reads a raw streaming upload's intent from the request headers.
|
||||
*
|
||||
* <p>The filename arrives in a header and is treated exactly like a multipart filename: untrusted
|
||||
* display data that the application layer sanitizes. This mapper never opens the body, so a
|
||||
* rejected request costs no bytes.
|
||||
*/
|
||||
public final class RawUploadRequestMapper {
|
||||
|
||||
public static final String FILENAME_HEADER = "X-Filename";
|
||||
public static final String DIGEST_HEADER = "X-Content-Sha256";
|
||||
|
||||
private static final String FALLBACK_FILENAME = "upload.bin";
|
||||
|
||||
private final boolean contentLengthRequired;
|
||||
|
||||
public RawUploadRequestMapper(boolean contentLengthRequired) {
|
||||
this.contentLengthRequired = contentLengthRequired;
|
||||
}
|
||||
|
||||
public UploadIntent map(HttpServletRequest request) {
|
||||
OptionalLong declaredLength = declaredLength(request);
|
||||
if (contentLengthRequired && declaredLength.isEmpty()) {
|
||||
throw new UnsupportedMediaTypeException(
|
||||
"this profile requires a declared Content-Length",
|
||||
FileserverFailureContext.of(FileserverErrorCode.CONTENT_LENGTH_REQUIRED, false));
|
||||
}
|
||||
return new UploadIntent(
|
||||
filename(request), claimedMediaType(request), declaredLength, digest(request));
|
||||
}
|
||||
|
||||
private static String filename(HttpServletRequest request) {
|
||||
String header = request.getHeader(FILENAME_HEADER);
|
||||
return header == null || header.isBlank() ? FALLBACK_FILENAME : header;
|
||||
}
|
||||
|
||||
private static Optional<String> claimedMediaType(HttpServletRequest request) {
|
||||
String contentType = request.getContentType();
|
||||
return contentType == null || contentType.isBlank()
|
||||
? Optional.empty()
|
||||
: Optional.of(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the declared length.
|
||||
*
|
||||
* <p>A chunked request has no length; that is legal and the streamed hard limit still applies, so
|
||||
* an absent value is reported as absent rather than as zero.
|
||||
*/
|
||||
private static OptionalLong declaredLength(HttpServletRequest request) {
|
||||
long length = request.getContentLengthLong();
|
||||
return length < 0 ? OptionalLong.empty() : OptionalLong.of(length);
|
||||
}
|
||||
|
||||
private static Optional<String> digest(HttpServletRequest request) {
|
||||
String header = request.getHeader(DIGEST_HEADER);
|
||||
if (header == null || header.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(header.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Transport-side reading of one upload's headers or part metadata.
|
||||
*
|
||||
* <p>It exists so the raw and multipart paths converge on one shape before anything reaches the
|
||||
* application layer. Every field is untrusted client input; nothing here is used to build a
|
||||
* physical key.
|
||||
*/
|
||||
public record UploadIntent(
|
||||
String originalFilename,
|
||||
Optional<String> claimedMediaType,
|
||||
OptionalLong declaredLength,
|
||||
Optional<String> expectedSha256) {
|
||||
|
||||
public UploadIntent {
|
||||
Objects.requireNonNull(originalFilename, "originalFilename");
|
||||
Objects.requireNonNull(claimedMediaType, "claimedMediaType");
|
||||
Objects.requireNonNull(declaredLength, "declaredLength");
|
||||
Objects.requireNonNull(expectedSha256, "expectedSha256");
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
import dev.caskeleton.application.fileserver.api.error.InvalidPathException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Rebuilds the internal URI from a validated content key.
|
||||
*
|
||||
* <p>Nothing here concatenates client input. The key has already been through {@link ContentKey}'s
|
||||
* character class, and this class re-checks the sharded shape before emitting a URI, because a
|
||||
* header that reaches Nginx as an internal redirect is effectively a filesystem lookup: a traversal
|
||||
* that survived to this point would be served, not rejected.
|
||||
*/
|
||||
public final class DefaultNginxInternalUriMapper implements NginxInternalUriMapper {
|
||||
|
||||
private static final Pattern SHARDED_KEY =
|
||||
Pattern.compile("[a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}");
|
||||
|
||||
/** A well-formed key that names nothing; only the mapping's shape is under test. */
|
||||
private static final String ATTESTATION_KEY = "00/00/startup-attestation";
|
||||
|
||||
private final NginxDelegationProperties properties;
|
||||
|
||||
public DefaultNginxInternalUriMapper(NginxDelegationProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String map(ContentKey key) {
|
||||
return mapUnchecked(key.value());
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trips a representative key through the configured prefix and suffix.
|
||||
*
|
||||
* <p>A representative key rather than a real one: the attestation has to run before any object
|
||||
* exists, and what it checks is the shape of the configuration, not the presence of content.
|
||||
*/
|
||||
@Override
|
||||
public boolean attestMapping() {
|
||||
try {
|
||||
String uri = mapUnchecked(ATTESTATION_KEY);
|
||||
return uri.startsWith("/")
|
||||
&& uri.startsWith(properties.internalPrefix())
|
||||
&& uri.endsWith(properties.objectSuffix())
|
||||
&& uri.contains(ATTESTATION_KEY);
|
||||
} catch (InvalidPathException misconfigured) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String mapUnchecked(String rawKey) {
|
||||
if (rawKey == null || !SHARDED_KEY.matcher(rawKey).matches()) {
|
||||
throw InvalidPathException.of("content key is not a valid sharded object key");
|
||||
}
|
||||
String uri = properties.internalPrefix() + rawKey + properties.objectSuffix();
|
||||
if (uri.contains("..") || uri.contains("//") || uri.indexOf('\\') >= 0) {
|
||||
throw InvalidPathException.of("internal uri failed its post-construction check");
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Settings for handing large transfers to the front proxy.
|
||||
*
|
||||
* <p>The internal prefix must match an Nginx {@code location} marked {@code internal}; if it is
|
||||
* not, the prefix becomes a publicly reachable path to raw content, so startup validates it rather
|
||||
* than trusting configuration.
|
||||
*/
|
||||
public record NginxDelegationProperties(
|
||||
boolean enabled, String internalPrefix, String objectSuffix, long minimumBytes) {
|
||||
|
||||
private static final Pattern SAFE_PREFIX = Pattern.compile("/[A-Za-z0-9_/-]{1,64}");
|
||||
private static final Pattern SAFE_SUFFIX = Pattern.compile("(\\.[a-z0-9]{1,8})?");
|
||||
|
||||
public NginxDelegationProperties {
|
||||
Objects.requireNonNull(internalPrefix, "internalPrefix");
|
||||
Objects.requireNonNull(objectSuffix, "objectSuffix");
|
||||
if (!SAFE_PREFIX.matcher(internalPrefix).matches() || !internalPrefix.endsWith("/")) {
|
||||
throw new IllegalArgumentException("internalPrefix must be a safe rooted path ending in '/'");
|
||||
}
|
||||
if (!SAFE_SUFFIX.matcher(objectSuffix).matches()) {
|
||||
throw new IllegalArgumentException("objectSuffix must be empty or a short lowercase suffix");
|
||||
}
|
||||
if (minimumBytes < 0) {
|
||||
throw new IllegalArgumentException("minimumBytes must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/** Design default: disabled, {@code /__files/} prefix, {@code .bin} objects, 16 MiB threshold. */
|
||||
public static NginxDelegationProperties disabled() {
|
||||
return new NginxDelegationProperties(false, "/__files/", ".bin", 16L * 1024 * 1024);
|
||||
}
|
||||
|
||||
public static NginxDelegationProperties enabledWithDefaults() {
|
||||
return new NginxDelegationProperties(true, "/__files/", ".bin", 16L * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
|
||||
|
||||
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* Decides whether one authorized download is handed to the front proxy, and writes the handoff.
|
||||
*
|
||||
* <p>Delegation happens strictly <em>after</em> authorization and the READY gate, so the internal
|
||||
* redirect can only ever name content the caller was already allowed to read. A partial or
|
||||
* conditional answer is never delegated: the proxy would have to re-derive the range and validator
|
||||
* decisions, and two implementations of that logic is exactly the drift this design forbids.
|
||||
*/
|
||||
public final class NginxDownloadStrategy {
|
||||
|
||||
/** Header Nginx consumes; it must never be copied through to the client. */
|
||||
public static final String ACCEL_REDIRECT_HEADER = "X-Accel-Redirect";
|
||||
|
||||
private final NginxDelegationProperties properties;
|
||||
private final NginxInternalUriMapper uriMapper;
|
||||
|
||||
public NginxDownloadStrategy(
|
||||
NginxDelegationProperties properties, NginxInternalUriMapper uriMapper) {
|
||||
this.properties = properties;
|
||||
this.uriMapper = uriMapper;
|
||||
}
|
||||
|
||||
/** True when this descriptor should be transferred by the proxy rather than in-process. */
|
||||
public boolean shouldDelegate(DownloadDescriptor descriptor) {
|
||||
return properties.enabled()
|
||||
&& descriptor.bodyExpected()
|
||||
&& !descriptor.isPartial()
|
||||
&& descriptor.status() == HttpServletResponse.SC_OK
|
||||
&& descriptor.representation().length() >= properties.minimumBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the handoff.
|
||||
*
|
||||
* <p>{@code Content-Length} is deliberately cleared: the proxy sets it from the file it actually
|
||||
* sends, and a stale value from the metadata store would truncate or hang the response if the two
|
||||
* ever disagreed.
|
||||
*/
|
||||
public void delegate(DownloadDescriptor descriptor, HttpServletResponse response) {
|
||||
response.setHeader(ACCEL_REDIRECT_HEADER, uriMapper.map(descriptor.contentKey()));
|
||||
response.setHeader("Content-Length", null);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.ContentKey;
|
||||
|
||||
/**
|
||||
* Turns a server-generated content key into the internal URI the front proxy serves.
|
||||
*
|
||||
* <p>The result is always relative and always below the configured internal prefix. An absolute
|
||||
* physical path never crosses this boundary — the proxy resolves the prefix to a filesystem root
|
||||
* itself, so the application never has to disclose where content lives.
|
||||
*/
|
||||
public interface NginxInternalUriMapper {
|
||||
|
||||
String map(ContentKey key);
|
||||
|
||||
/**
|
||||
* Maps a raw string, validating it first.
|
||||
*
|
||||
* <p>This exists because internal callers are exactly where an unvalidated key would otherwise
|
||||
* slip through; it validates rather than trusting the caller.
|
||||
*/
|
||||
String mapUnchecked(String rawKey);
|
||||
|
||||
/**
|
||||
* Proves the configured mapping actually produces a usable internal URI.
|
||||
*
|
||||
* <p>Called once at startup instead of reading a setting in which a deployment asserts its own
|
||||
* correctness. The failure this catches is silent by nature: a prefix the proxy does not resolve
|
||||
* makes the server answer {@code 200} with an empty body, so the client believes it received the
|
||||
* file. Better to refuse to start.
|
||||
*/
|
||||
boolean attestMapping();
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.error.FileserverException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* Maps every Fileserver failure onto its design status and problem document.
|
||||
*
|
||||
* <p>It is ordered ahead of the base operational handler, whose catch-all would otherwise resolve
|
||||
* these to a generic internal error and lose the code. The status comes from the error code itself,
|
||||
* so Spring MVC, Spring WebFlux, and the Nginx delegation path cannot drift apart.
|
||||
*
|
||||
* <p>Two headers are part of the contract rather than decoration: {@code Retry-After} on a
|
||||
* retryable rejection, and the unsatisfied-range form of {@code Content-Range} on {@code 416},
|
||||
* which is how a client learns the real representation length.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class FileserverExceptionHandler {
|
||||
|
||||
private final FileserverProblemFactory problemFactory;
|
||||
|
||||
public FileserverExceptionHandler(FileserverProblemFactory problemFactory) {
|
||||
this.problemFactory = problemFactory;
|
||||
}
|
||||
|
||||
@ExceptionHandler(FileserverException.class)
|
||||
public ResponseEntity<FileserverProblem> handle(
|
||||
FileserverException failure, HttpServletRequest request) {
|
||||
FileserverProblem problem = problemFactory.create(failure.context(), request.getRequestURI());
|
||||
ResponseEntity.BodyBuilder response =
|
||||
ResponseEntity.status(problem.status()).contentType(MediaType.APPLICATION_PROBLEM_JSON);
|
||||
|
||||
// The header policy is shared with the reactive router; neither transport owns it.
|
||||
FileserverProblemHeaders.of(failure).forEach(response::header);
|
||||
return response.body(problem);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
/**
|
||||
* RFC 9457 problem document for a Fileserver failure.
|
||||
*
|
||||
* <p>This is a transport-owned record rather than the framework's problem type, and it carries only
|
||||
* the stable code plus the correlation fields the client can act on. The server-side exception
|
||||
* message, physical path, mount, scanner credential, and filename never appear here.
|
||||
*
|
||||
* <p>{@code ambiguous} and {@code reconciliationRequired} are exposed deliberately: a client that
|
||||
* gets an ambiguous failure must not blindly retry, because the operation may already have taken
|
||||
* effect.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record FileserverProblem(
|
||||
String type,
|
||||
String title,
|
||||
int status,
|
||||
String code,
|
||||
String detail,
|
||||
String instance,
|
||||
String traceId,
|
||||
boolean retryable,
|
||||
boolean ambiguous,
|
||||
boolean reconciliationRequired,
|
||||
String fileId,
|
||||
String uploadId,
|
||||
Long expectedOffset,
|
||||
Long currentOffset,
|
||||
String state) {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user