feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
This commit is contained in:
@@ -1,233 +0,0 @@
|
||||
# ----------------------------------------------------------------------------
|
||||
# 외부화 설정의 단일 출처. 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,369 @@
|
||||
# =============================================================================
|
||||
# The public catalogue of every environment variable this application reads.
|
||||
#
|
||||
# Generated from docs/registries/env-keys.yaml, which is the SSOT. Copy this file to src/.env and
|
||||
# fill in the values your deployment needs; src/.env is gitignored because it is operator input,
|
||||
# not a build input.
|
||||
#
|
||||
# Secret-classified keys are left empty on purpose. An example that carries a working credential
|
||||
# is a credential in the repository, and the fact that it is 'only an example' is not something a
|
||||
# scanner, a fork, or a hurried operator can tell.
|
||||
#
|
||||
# A key left blank here uses the inline default in application.yml. Seven values have no default
|
||||
# at all — the datasource URL, username and password, the application name, and the JWT issuer and
|
||||
# audience — because a default for any of them is a deployment running against something nobody
|
||||
# chose.
|
||||
# =============================================================================
|
||||
|
||||
# ---- Configuration -----------------------------------------------------------
|
||||
SPRING_PROFILES_ACTIVE=
|
||||
APP_NAME=
|
||||
APP_SERVER_PORT=8080
|
||||
MANAGEMENT_SERVER_PORT=9001
|
||||
APP_SERVER_SHUTDOWN=graceful
|
||||
APP_SERVER_SHUTDOWN_TIMEOUT=30s
|
||||
APP_SERVER_FORWARD_HEADERS_STRATEGY=framework
|
||||
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=1KB
|
||||
APP_SERVER_ERROR_INCLUDE_STACKTRACE=never
|
||||
APP_SERVER_ERROR_INCLUDE_MESSAGE=never
|
||||
APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
|
||||
APP_LOG_BODY_CAPTURE_ENABLED=false
|
||||
APP_MULTI_INSTANCE_ENABLED=false
|
||||
APP_MIGRATION_ON_STARTUP=true
|
||||
APP_DATASOURCE_URL=
|
||||
APP_DATASOURCE_USERNAME=
|
||||
APP_DATASOURCE_POOL_MAX_SIZE=10
|
||||
APP_DATASOURCE_POOL_MIN_IDLE=2
|
||||
APP_DATASOURCE_CONNECTION_TIMEOUT=5s
|
||||
APP_DATASOURCE_DRIVER=org.postgresql.Driver
|
||||
APP_DATASOURCE_DDL_AUTO=validate
|
||||
APP_DATASOURCE_SHOW_SQL=false
|
||||
APP_DATASOURCE_FORMAT_SQL=false
|
||||
APP_DATASOURCE_OPEN_IN_VIEW=false
|
||||
APP_DATASOURCE_POOL_IDLE_TIMEOUT=600000
|
||||
APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
APP_TRACING_ENABLED=true
|
||||
APP_TRACING_SAMPLE_RATE=
|
||||
APP_LOG_LEVEL_ROOT=INFO
|
||||
APP_LOG_LEVEL_APP=INFO
|
||||
APP_LOG_LEVEL_SPRING=INFO
|
||||
APP_LOG_LEVEL_WEB=INFO
|
||||
APP_LOG_LEVEL_SQL=WARN
|
||||
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
|
||||
APP_LOG_ASYNC_ENABLED=true
|
||||
APP_LOG_ASYNC_QUEUE_SIZE=512
|
||||
APP_LOG_ASYNC_DISCARDING_THRESHOLD=20
|
||||
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
|
||||
APP_LOG_SAMPLING_RATE=1.0
|
||||
APP_SECURITY_CORS_ORIGINS=
|
||||
APP_SECURITY_CORS_ENABLED=false
|
||||
APP_SECURITY_CORS_ALLOWED_METHODS=
|
||||
APP_SECURITY_CORS_ALLOWED_HEADERS=*
|
||||
APP_SECURITY_CORS_ALLOW_CREDENTIALS=false
|
||||
APP_SECURITY_CORS_MAX_AGE=600s
|
||||
APP_SECURITY_AUTH_MODE=jwt
|
||||
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
|
||||
APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT=local
|
||||
APP_SESSION_IDLE_TIMEOUT=30m
|
||||
APP_SESSION_ABSOLUTE_LIFETIME=8h
|
||||
APP_SESSION_TOUCH_INTERVAL=1m
|
||||
APP_SESSION_TOMBSTONE_TTL=5m
|
||||
APP_SESSION_MAXIMUM_ENVELOPE_BYTES=32768
|
||||
APP_SESSION_MAXIMUM_ATTRIBUTES=64
|
||||
APP_SESSION_MAXIMUM_SCALAR_BYTES=8192
|
||||
APP_SECURITY_JWT_ISSUER=
|
||||
APP_SECURITY_JWT_AUDIENCE=
|
||||
APP_SECURITY_JWT_JWKS_URI=
|
||||
APP_SECURITY_JWT_CLOCK_SKEW=60s
|
||||
APP_TENANT_ENABLED=false
|
||||
APP_RATE_LIMIT_ENABLED=false
|
||||
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
|
||||
APP_RATE_LIMIT_REDIS_ENABLED=false
|
||||
APP_RATE_LIMIT_PROVIDER=disabled
|
||||
APP_RATE_LIMIT_ROLE=coordination
|
||||
APP_RATE_LIMIT_FAILURE_POLICY=fail-closed
|
||||
APP_RATE_LIMIT_DEFAULT_POLICY_ID=api-default
|
||||
APP_RATE_LIMIT_FAILURE_RETRY_AFTER=100ms
|
||||
APP_RATE_LIMIT_HASH_KEY_VERSION=1
|
||||
APP_RATE_LIMIT_KEY_VERSION=1
|
||||
APP_RATE_LIMIT_REDIS_HOST=
|
||||
APP_RATE_LIMIT_REDIS_PORT=6379
|
||||
APP_RATE_LIMIT_REDIS_TRUST_PEM=
|
||||
APP_SESSION_REDIS_TRUST_PEM=
|
||||
APP_RATE_LIMIT_REDIS_COMMAND_TIMEOUT=1s
|
||||
APP_RATE_LIMIT_REDIS_MAXIMUM_COMMAND_BYTES=16384
|
||||
APP_RATE_LIMIT_REDIS_MAXIMUM_QUEUED_COMMANDS=32
|
||||
APP_RATE_LIMIT_REDIS_MAXIMUM_IN_FLIGHT_BYTES=1048576
|
||||
APP_RATE_LIMIT_REDIS_NAMESPACE_ENVIRONMENT=local
|
||||
APP_RATE_LIMIT_POLICY_REVISION=v1
|
||||
APP_RATE_LIMIT_ALGORITHM=sliding-counter
|
||||
APP_RATE_LIMIT_LIMIT=100
|
||||
APP_RATE_LIMIT_WINDOW=1s
|
||||
APP_RATE_LIMIT_CAPACITY=100
|
||||
APP_RATE_LIMIT_REFILL_TOKENS=100
|
||||
APP_RATE_LIMIT_REFILL_PERIOD=1s
|
||||
APP_RATE_LIMIT_MAXIMUM_COST=10
|
||||
APP_RATE_LIMIT_CLEANUP_GRACE=5s
|
||||
APP_RATE_LIMIT_MAXIMUM_CLOCK_REGRESSION=250ms
|
||||
APP_IDEMPOTENCY_TTL=24h
|
||||
APP_IDEMPOTENCY_PROVIDER=jdbc
|
||||
APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local
|
||||
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
|
||||
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
|
||||
APP_LEASE_PROVIDER=disabled
|
||||
APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local
|
||||
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
|
||||
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
|
||||
APP_REDIS_ENABLED=false
|
||||
APP_CACHE_REDIS_POSITIVE_HARD_TTL=5m
|
||||
APP_CACHE_REDIS_NEGATIVE_TTL=10s
|
||||
APP_IDEMPOTENCY_REDIS_COMMAND_TIMEOUT=200ms
|
||||
APP_LEASE_REDIS_COMMAND_TIMEOUT=200ms
|
||||
APP_LEASE_REDIS_CONTENTION_RETRY_AFTER=50ms
|
||||
APP_REDIS_ACKNOWLEDGED_WRITE_LOSS_ACCEPTED=false
|
||||
APP_REDIS_ADMIN_CREDENTIAL_REFERENCE=
|
||||
APP_REDIS_ADMIN_ENABLED=false
|
||||
APP_REDIS_ADVANCED_ENABLED=false
|
||||
APP_REDIS_ADVANCED_POLICIES=
|
||||
APP_REDIS_BLOCKING_MAX_BLOCK=30s
|
||||
APP_REDIS_BLOCKING_MAX_CONNECTIONS=32
|
||||
APP_REDIS_DATABASE=0
|
||||
APP_REDIS_LIMITS_MAX_BATCH_COMMANDS=500
|
||||
APP_REDIS_LIMITS_MAX_BATCH_REPLY_BYTES=16777216
|
||||
APP_REDIS_LIMITS_MAX_BATCH_REQUEST_BYTES=4194304
|
||||
APP_REDIS_LIMITS_MAX_BITMAP_OFFSET=10000000
|
||||
APP_REDIS_LIMITS_MAX_COLLECTION_ELEMENTS=1000
|
||||
APP_REDIS_LIMITS_MAX_HASH_FIELD_VALUE_BYTES=524288
|
||||
APP_REDIS_LIMITS_MAX_KEY_BYTES=512
|
||||
APP_REDIS_LIMITS_MAX_SCAN_COUNT=500
|
||||
APP_REDIS_LIMITS_MAX_STREAM_PAYLOAD_BYTES=262144
|
||||
APP_REDIS_LIMITS_MAX_VALUE_BYTES=1048576
|
||||
APP_REDIS_LIMITS_OFFLINE_QUEUE_COMMANDS=1000
|
||||
APP_REDIS_MODE=standalone
|
||||
APP_REDIS_NAMESPACE_DOMAIN=shared
|
||||
APP_REDIS_NAMESPACE_ENVIRONMENT=local
|
||||
APP_REDIS_NAMESPACE_SERVICE=sample-service
|
||||
APP_REDIS_NODES=localhost:6379
|
||||
APP_REDIS_RAW_CREDENTIAL_REFERENCE=
|
||||
APP_REDIS_RAW_ENABLED=false
|
||||
APP_REDIS_RAW_POLICY_RESOURCE=classpath:redis-sdk/raw-command-allowlist.yml
|
||||
APP_REDIS_TIMEOUT_ADMIN=3s
|
||||
APP_REDIS_TIMEOUT_BATCH=2s
|
||||
APP_REDIS_TIMEOUT_COLLECTION=2s
|
||||
APP_REDIS_TIMEOUT_FAST=500ms
|
||||
APP_REDIS_TIMEOUT_SCRIPT=1s
|
||||
APP_REDIS_TRANSACTION_MAX_CONNECTIONS=16
|
||||
APP_REDIS_AUTHENTICATION_ADVANCED_CREDENTIAL_REFERENCE=
|
||||
APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED=false
|
||||
APP_REDIS_AUTHENTICATION_CREDENTIAL_REFERENCE=
|
||||
APP_REDIS_AUTHENTICATION_PUBSUB_CREDENTIAL_REFERENCE=
|
||||
APP_REDIS_CAPACITY_MAXIMUM_IN_FLIGHT_BYTES=
|
||||
APP_REDIS_CAPACITY_MAXIMUM_IN_FLIGHT_COMMANDS=64
|
||||
APP_REDIS_CAPACITY_MAXIMUM_REPLY_BYTES=
|
||||
APP_REDIS_CAPACITY_REJECT_WHEN_DISCONNECTED=true
|
||||
APP_REDIS_CLUSTER_MAXIMUM_REDIRECTS=5
|
||||
APP_REDIS_CLUSTER_TOPOLOGY_REFRESH_PERIOD=30s
|
||||
APP_REDIS_LIFECYCLE_ACQUIRE_TIMEOUT=2s
|
||||
APP_REDIS_LIFECYCLE_CLIENT_NAME=ca-skeleton
|
||||
APP_REDIS_LIFECYCLE_CONNECT_TIMEOUT=2s
|
||||
APP_REDIS_LIFECYCLE_DRAIN_TIMEOUT=6s
|
||||
APP_REDIS_LIFECYCLE_SHUTDOWN_QUIET_PERIOD=100ms
|
||||
APP_REDIS_LIFECYCLE_SHUTDOWN_TIMEOUT=3s
|
||||
APP_REDIS_LIFECYCLE_TLS_HANDSHAKE_TIMEOUT=3s
|
||||
APP_REDIS_PUBSUB_BUFFER_CAPACITY=1024
|
||||
APP_REDIS_PUBSUB_OVERFLOW_POLICY=error
|
||||
APP_REDIS_SENTINEL_CREDENTIAL_REFERENCE=
|
||||
APP_REDIS_SENTINEL_MASTER_NAME=
|
||||
APP_REDIS_SENTINEL_NODES=app.redis.nodes
|
||||
APP_REDIS_TLS_CLIENT_CERTIFICATE_RESOURCE=
|
||||
APP_REDIS_TLS_CLIENT_KEY_REFERENCE=
|
||||
APP_REDIS_TLS_ENABLED=false
|
||||
APP_REDIS_TLS_HOSTNAME_VERIFICATION=true
|
||||
APP_REDIS_TLS_TRUST_MATERIAL_RESOURCE=
|
||||
APP_CACHE_REDIS_ENABLED=false
|
||||
APP_CACHE_REDIS_CLIENT_MODE=managed
|
||||
APP_CACHE_REDIS_HOST=
|
||||
APP_CACHE_REDIS_PORT=6379
|
||||
APP_CACHE_REDIS_TRUST_PEM=
|
||||
APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s
|
||||
APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s
|
||||
APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS=15s
|
||||
APP_CACHE_REDIS_COMMAND_TIMEOUT=2s
|
||||
APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS=8
|
||||
APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216
|
||||
APP_CACHE_REDIS_POSITIVE_SOFT_TTL=
|
||||
APP_CACHE_REDIS_TTL_JITTER=0.10
|
||||
APP_CACHE_REDIS_MINIMUM_HARD_TTL=1s
|
||||
APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local
|
||||
APP_CACHE_REDIS_SEMANTIC_REGION=default
|
||||
APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576
|
||||
APP_CACHE_REDIS_L1_ENABLED=false
|
||||
APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES=10000
|
||||
APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES=67108864
|
||||
APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES=1048576
|
||||
APP_CACHE_REDIS_L1_TTL=30s
|
||||
APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL=5s
|
||||
APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024
|
||||
APP_CACHE_DEFAULT_TTL=300s
|
||||
APP_CACHE_NEGATIVE_TTL=60s
|
||||
APP_MESSAGING_BROKER=
|
||||
APP_MESSAGING_KAFKA_BROKERS=
|
||||
APP_NOTIFICATION_SLACK_PROVIDER=
|
||||
APP_NOTIFICATION_EMAIL_PROVIDER=
|
||||
APP_FILESERVER_ENABLED=false
|
||||
APP_FILESERVER_LOCAL_ROOT=
|
||||
APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME=
|
||||
APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE=
|
||||
APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256=
|
||||
APP_FILESERVER_LOCAL_EXPECTED_OWNER=
|
||||
APP_HTTPCLIENT_ENABLED=false
|
||||
APP_FILESERVER_PLATFORM_ENABLED=false
|
||||
APP_FILESERVER_PLATFORM_INSTANCE_ID=local-node
|
||||
APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE=default
|
||||
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
|
||||
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
|
||||
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
|
||||
APP_FILE_UPLOAD_MAX_SIZE=10MB
|
||||
APP_FILE_UPLOAD_GLOBAL_REQUEST_MAX_SIZE=12MB
|
||||
APP_ASYNC_EXECUTOR_CORE_SIZE=10
|
||||
APP_ASYNC_EXECUTOR_MAX_SIZE=50
|
||||
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
|
||||
APP_PERSISTENCE_JPA_ENABLED=false
|
||||
APP_PERSISTENCE_MONGO_ENABLED=false
|
||||
APP_PERSISTENCE_MONGO_ACTIVE_PROFILE=
|
||||
APP_MESSAGING_ENABLED=false
|
||||
APP_GRAPHQL_ENABLED=false
|
||||
APP_GRAPHQL_DEPLOYMENT_MODE=
|
||||
APP_OUTBOX_ENABLED=false
|
||||
APP_OUTBOX_RELAY_ENABLED=false
|
||||
APP_NOTIFICATION_PLATFORM_ENABLED=false
|
||||
APP_NOTIFICATION_PLATFORM_MODE=SERVING
|
||||
# OpenAPI exposure. application-prod.yml pins both false regardless of these.
|
||||
APP_OPENAPI_DOCS_ENABLED=true
|
||||
APP_OPENAPI_UI_ENABLED=true
|
||||
# The shipped SMTP provider profile and the relay it uses. Off by default; the relay address
|
||||
# itself is spring.mail.* (SPRING_MAIL_HOST / SPRING_MAIL_PORT), not repeated here.
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_ENABLED=false
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_PRIMARY=true
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_ENVIRONMENT=local
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_CREDENTIAL_PROFILE=default
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_TIMEOUT=10s
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_MAX_CONCURRENCY=4
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_RATE_PER_SECOND=10
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_TLS_MODE=STARTTLS_REQUIRED
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_SENDER_IDENTITY=no-reply@example.invalid
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_CONNECT_TIMEOUT=5s
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_READ_TIMEOUT=10s
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_WRITE_TIMEOUT=10s
|
||||
APP_NOTIFICATION_PLATFORM_SMTP_DISPATCH_CONCURRENCY=4
|
||||
APP_NOTIFICATION_PLATFORM_CLAIM_BATCH_SIZE=50
|
||||
APP_NOTIFICATION_PLATFORM_LEASE_DURATION=2m
|
||||
APP_NOTIFICATION_PLATFORM_POLL_INTERVAL=1s
|
||||
APP_NOTIFICATION_PLATFORM_MAX_CONCURRENCY=64
|
||||
APP_NOTIFICATION_PLATFORM_MAX_ADDITIONAL_ATTEMPTS=4
|
||||
APP_NOTIFICATION_PLATFORM_MAX_QUEUE_AGE=24h
|
||||
APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK=false
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=false
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES=65508
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW=5m
|
||||
|
||||
# ---- Secrets — supply out of band; never commit a value here ------------------
|
||||
APP_DATASOURCE_PASSWORD=
|
||||
APP_PRIVACY_PSEUDONYMIZATION_SALT=
|
||||
APP_SECURITY_JWT_SIGNING_KEY=
|
||||
APP_RATE_LIMIT_REDIS_PASSWORD=
|
||||
APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=
|
||||
APP_SESSION_REDIS_PASSWORD=
|
||||
APP_SESSION_REDIS_KEY_HMAC_SECRET=
|
||||
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
|
||||
APP_LEASE_REDIS_KEY_HMAC_SECRET=
|
||||
APP_CACHE_REDIS_PASSWORD=
|
||||
APP_CACHE_REDIS_KEY_HMAC_SECRET=
|
||||
APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY=
|
||||
# The notification platform's eight key purposes. Each is base64 of at least 32 bytes and must
|
||||
# differ from the other seven; the platform decodes all eight at startup and refuses to boot if one
|
||||
# is blank, short or shared. Only needed when APP_NOTIFICATION_PLATFORM_ENABLED=true.
|
||||
APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY=
|
||||
APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY=
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY=
|
||||
APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY=
|
||||
APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY=
|
||||
APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY=
|
||||
APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY=
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY=
|
||||
# The id each of those keys is active under — an identifier, not secret material, and required:
|
||||
# rotating the material without changing the id makes the new ciphertext indistinguishable from the
|
||||
# old. Any stable string per purpose; change it whenever the matching key changes.
|
||||
APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY_ID=
|
||||
APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY_ID=
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY_ID=
|
||||
APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY_ID=
|
||||
APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY_ID=
|
||||
APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY_ID=
|
||||
APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY_ID=
|
||||
APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY_ID=
|
||||
@@ -0,0 +1,36 @@
|
||||
# =============================================================================
|
||||
# A local opt-in example: the five adapters are off in the shipped defaults, and this shows what
|
||||
# turning some of them on looks like. Copy to src/.env.local and edit.
|
||||
#
|
||||
# Each switch here needs the infrastructure its Compose profile provides. Turning one on without
|
||||
# it is refused at startup by CapabilityDependencyValidator, which names the missing value.
|
||||
# =============================================================================
|
||||
|
||||
SPRING_PROFILES_ACTIVE=local
|
||||
|
||||
# JPA: needs the PostgreSQL service. Flyway owns the schema from dev onward, and local uses the
|
||||
# same vendor semantics so the two do not diverge.
|
||||
APP_PERSISTENCE_JPA_ENABLED=true
|
||||
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5432/ca_skeleton
|
||||
APP_DATASOURCE_USERNAME=ca_skeleton
|
||||
APP_DATASOURCE_PASSWORD=
|
||||
APP_DATASOURCE_DDL_AUTO=validate
|
||||
|
||||
# Messaging: the broker id selects the transport; the switch decides whether there is one at all.
|
||||
APP_MESSAGING_ENABLED=false
|
||||
APP_MESSAGING_BROKER=
|
||||
|
||||
# Outbox: needs JPA and messaging together, and a broker. relay-enabled only starts the scheduler.
|
||||
APP_OUTBOX_ENABLED=false
|
||||
APP_OUTBOX_RELAY_ENABLED=false
|
||||
|
||||
# Mongo: active-profile is required when the switch is on, and selects exactly one client.
|
||||
APP_PERSISTENCE_MONGO_ENABLED=false
|
||||
APP_PERSISTENCE_MONGO_ACTIVE_PROFILE=
|
||||
|
||||
# GraphQL: the deployment mode must match the runtime environment (local -> LOCAL).
|
||||
APP_GRAPHQL_ENABLED=false
|
||||
APP_GRAPHQL_DEPLOYMENT_MODE=LOCAL
|
||||
|
||||
# Notification: stores requests relationally, so it needs JPA on.
|
||||
APP_NOTIFICATION_PLATFORM_ENABLED=false
|
||||
@@ -21,8 +21,9 @@ leaf 안의 **bounded sub-package** 로 매핑한다(선례: httpclient leaf 가
|
||||
SSOT(`src/config/architecture/modules.json`)까지 밀어올리지 않는다는 선택이다.
|
||||
|
||||
**이는 레지스트리가 닫혀 있어서가 아니다.** `modules.json` 은 확장 가능하며, 실제로 자매
|
||||
플랫폼인 messaging 은 **정반대 선택**을 해서 24개 leaf 를 레지스트리에 등록했다(현재 총 43개
|
||||
leaf). 즉 이 레포에는 두 패턴이 공존한다:
|
||||
플랫폼인 messaging 은 **정반대 선택**을 해서 자기 leaf 들을 레지스트리에 개별 등록했다. 개수는
|
||||
`modules.json` 이 소유하며 여기서 되풀이하지 않는다 — 산문에 적힌 숫자는 leaf 가 하나 추가되는 순간
|
||||
낡는다. 즉 이 레포에는 두 패턴이 공존한다:
|
||||
|
||||
| | 방식 | 경계 강제 |
|
||||
| --- | --- | --- |
|
||||
@@ -140,6 +141,8 @@ health 스키마만 소유한다.
|
||||
| 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 |
|
||||
| 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` |
|
||||
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrar` + `dataloader/GraphQlBatchContractTest` |
|
||||
| cursor 서명 (`pagination/`) | `modelled` | `HmacGraphQlCursorCodec`·`GraphQlCursorKeyRing` 단위 테스트만. **auto-configuration 이 둘 중 무엇도 생성하지 않는다** — `autoconfigure/GraphQlPolicyRequestPathTest` 가 그 사실을 고정 |
|
||||
| mutation 멱등성 (`mutation/`) | `modelled` | `GraphQlMutationIdempotencyInterceptor` 를 참조하는 configuration 이 없다. 같은 테스트가 고정 |
|
||||
| persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 |
|
||||
| subscription / WebSocket / SSE / RSocket | `modelled` | 정책·상태기계 단위 테스트만. Spring transport handler 는 없다(그래서 타입 이름도 `*Admission` 이다) |
|
||||
| federation / incremental / codegen / compat | `modelled` | 단위 테스트만 |
|
||||
@@ -157,6 +160,14 @@ health 스키마만 소유한다.
|
||||
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다. 이 testkit 은
|
||||
**production jar 에 없다** — `src/testFixtures/java` 에 살고 `verifyGraphQlProductionJar` 가
|
||||
그 사실을 jar 내용으로 확인한다.
|
||||
- **cursor 서명이 요청 경로에 없다 (GQL-INT-003).** `backend.graphql.cursor.key-ids` 를 읽는 곳은
|
||||
둘 뿐이다: 프로덕션 기동을 거부하는 `GraphQlPlatformStartupValidator` 와 그 값을 돌려주는
|
||||
`GraphQlPlatformActuatorEndpoint`. **커서에 서명하는 코드는 아무것도 읽지 않는다.** 즉 프로덕션은
|
||||
키 식별자를 요구하고, 운영자가 넣고, 엔드포인트가 "설정됨"이라고 확인해 주는데, 커서는 validator
|
||||
메시지가 막는다고 말한 그대로 client-editable 로 남는다. 결함은 "미완성"이 아니라 **startup
|
||||
validator 가 하나를 완성된 것처럼 보이게 만든다**는 것이다. 닫으려면 배선이 아니라 설계 결정이
|
||||
필요하다 — `GraphQlCursorKeyRing.of` 는 `Map<String, byte[]>` 를 받고 설정 계약은 "키 자체는
|
||||
설정에 나타나지 않는다"이므로, **키 재료가 어디서 오는지**를 먼저 정해야 한다.
|
||||
- persisted operation 의 durable 저장 구현체 — 이 leaf 는 중립 계약
|
||||
`dev.caskeleton.shared.opstore.OperationalRecordStorePort` 에만 의존하고 key/value 매핑만
|
||||
소유한다. Postgres/Redis 구현체는 **그 중립 계약을** 구현하며, 이 leaf 의 타입을 구현하지
|
||||
|
||||
@@ -187,12 +187,17 @@ Closure<String> renderGraphQlApiSurface = {
|
||||
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
|
||||
}
|
||||
|
||||
// The approval flag is read at configuration time and carried in, not fetched from `project`
|
||||
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
|
||||
// incompatible with the configuration cache — which this build will need before it can adopt one.
|
||||
boolean graphQlApiSurfaceUpdateApproved = project.hasProperty('approveGraphQlApiSurfaceChange')
|
||||
|
||||
tasks.register('verifyGraphQlApiSurface') {
|
||||
group = 'verification'
|
||||
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
|
||||
|
||||
doLast {
|
||||
if (project.hasProperty('approveGraphQlApiSurfaceChange')) {
|
||||
if (graphQlApiSurfaceUpdateApproved) {
|
||||
throw new GradleException(
|
||||
'verifyGraphQlApiSurface is read-only; use updateGraphQlApiSurface to record an ' +
|
||||
'approved change.')
|
||||
|
||||
+2
-2
@@ -53,11 +53,11 @@ public record GraphQlAdvancedPromotionDecision(
|
||||
/**
|
||||
* Verifies a promotion is supported by evidence.
|
||||
*
|
||||
* @throws GraphQlAdvancedReleaseFailure when evidence is still outstanding
|
||||
* @throws GraphQlAdvancedReleaseException when evidence is still outstanding
|
||||
*/
|
||||
public void verify() {
|
||||
if (promotes() && !missingEvidence.isEmpty()) {
|
||||
throw new GraphQlAdvancedReleaseFailure(
|
||||
throw new GraphQlAdvancedReleaseException(
|
||||
capability.name() + " cannot be promoted with outstanding evidence: " + missingEvidence);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.release;
|
||||
*
|
||||
* <p>Names what is missing, so the gate is a checklist rather than a wall.
|
||||
*/
|
||||
public class GraphQlAdvancedReleaseFailure extends RuntimeException {
|
||||
public class GraphQlAdvancedReleaseException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -14,7 +14,7 @@ public class GraphQlAdvancedReleaseFailure extends RuntimeException {
|
||||
*
|
||||
* @param reason which evidence is missing
|
||||
*/
|
||||
public GraphQlAdvancedReleaseFailure(String reason) {
|
||||
public GraphQlAdvancedReleaseException(String reason) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -15,15 +15,15 @@ public final class GraphQlAdvancedReleaseGate {
|
||||
/**
|
||||
* Verifies an Advanced release.
|
||||
*
|
||||
* @throws GraphQlAdvancedReleaseFailure naming the missing evidence
|
||||
* @throws GraphQlAdvancedReleaseException naming the missing evidence
|
||||
*/
|
||||
public void verify(GraphQlAdvancedReleaseEvidence evidence) {
|
||||
if (!evidence.stableBaselinePassed()) {
|
||||
throw new GraphQlAdvancedReleaseFailure("stable graphql baseline must pass first");
|
||||
throw new GraphQlAdvancedReleaseException("stable graphql baseline must pass first");
|
||||
}
|
||||
List<String> missing = missing(evidence);
|
||||
if (!missing.isEmpty()) {
|
||||
throw new GraphQlAdvancedReleaseFailure("advanced graphql evidence incomplete: " + missing);
|
||||
throw new GraphQlAdvancedReleaseException("advanced graphql evidence incomplete: " + missing);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -34,12 +34,12 @@ public final class GraphQlAdvancedRunbookIndex {
|
||||
/**
|
||||
* Requires a capability to have a runbook.
|
||||
*
|
||||
* @throws GraphQlAdvancedReleaseFailure when it does not
|
||||
* @throws GraphQlAdvancedReleaseException when it does not
|
||||
*/
|
||||
public String require(GraphQlAdvancedCapability capability) {
|
||||
String reference = runbooks.get(capability);
|
||||
if (reference == null) {
|
||||
throw new GraphQlAdvancedReleaseFailure(
|
||||
throw new GraphQlAdvancedReleaseException(
|
||||
capability.name() + " cannot be released without an operational runbook");
|
||||
}
|
||||
return reference;
|
||||
|
||||
+2
-2
@@ -61,11 +61,11 @@ public final class GraphQlWebSocketAdmission {
|
||||
/**
|
||||
* Verifies the sub-protocol a client negotiated.
|
||||
*
|
||||
* @throws GraphQlWebSocketProtocolError when the client asked for an unsupported protocol
|
||||
* @throws GraphQlWebSocketProtocolException when the client asked for an unsupported protocol
|
||||
*/
|
||||
public void requireSupportedSubProtocol(String requested) {
|
||||
if (!supportedSubProtocols().contains(requested)) {
|
||||
throw new GraphQlWebSocketProtocolError("unsupported GraphQL WebSocket sub-protocol");
|
||||
throw new GraphQlWebSocketProtocolException("unsupported GraphQL WebSocket sub-protocol");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -54,14 +54,14 @@ public final class GraphQlWebSocketLifecycle {
|
||||
/**
|
||||
* Handles {@code connection_init}.
|
||||
*
|
||||
* @throws GraphQlWebSocketProtocolError when it arrives late or twice
|
||||
* @throws GraphQlWebSocketProtocolException when it arrives late or twice
|
||||
*/
|
||||
public void onConnectionInit(Instant now) {
|
||||
if (now.isAfter(connectedAt.plus(initTimeout))) {
|
||||
throw new GraphQlWebSocketProtocolError("connection_init timeout");
|
||||
throw new GraphQlWebSocketProtocolException("connection_init timeout");
|
||||
}
|
||||
if (initialised) {
|
||||
throw new GraphQlWebSocketProtocolError("connection_init sent twice");
|
||||
throw new GraphQlWebSocketProtocolException("connection_init sent twice");
|
||||
}
|
||||
initialised = true;
|
||||
}
|
||||
@@ -69,18 +69,19 @@ public final class GraphQlWebSocketLifecycle {
|
||||
/**
|
||||
* Handles {@code subscribe}.
|
||||
*
|
||||
* @throws GraphQlWebSocketProtocolError before initialisation, past the limit, or while draining
|
||||
* @throws GraphQlWebSocketProtocolException before initialisation, past the limit, or while
|
||||
* draining
|
||||
*/
|
||||
public void onSubscribe() {
|
||||
if (!initialised) {
|
||||
throw new GraphQlWebSocketProtocolError("subscribe before connection_init");
|
||||
throw new GraphQlWebSocketProtocolException("subscribe before connection_init");
|
||||
}
|
||||
if (draining) {
|
||||
throw new GraphQlWebSocketProtocolError(
|
||||
throw new GraphQlWebSocketProtocolException(
|
||||
"server is draining and accepts no new subscriptions");
|
||||
}
|
||||
if (subscriptions >= maximumSubscriptions) {
|
||||
throw new GraphQlWebSocketProtocolError("maximum subscriptions per connection exceeded");
|
||||
throw new GraphQlWebSocketProtocolException("maximum subscriptions per connection exceeded");
|
||||
}
|
||||
subscriptions++;
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.websocket;
|
||||
*
|
||||
* <p>Carries no {@code connection_init} payload: that payload is where credentials arrive.
|
||||
*/
|
||||
public class GraphQlWebSocketProtocolError extends RuntimeException {
|
||||
public class GraphQlWebSocketProtocolException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -14,7 +14,7 @@ public class GraphQlWebSocketProtocolError extends RuntimeException {
|
||||
*
|
||||
* @param reason bounded description of the violation
|
||||
*/
|
||||
public GraphQlWebSocketProtocolError(String reason) {
|
||||
public GraphQlWebSocketProtocolException(String reason) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.EnvironmentPostProcessor;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
|
||||
/**
|
||||
* Everything about {@code backend.graphql} that has to be settled before a bean exists.
|
||||
*
|
||||
* <p>An environment post-processor rather than a bean, for the same reason the master switch uses
|
||||
* one: these are the failures that have to arrive first, naming what an operator set, instead of
|
||||
* surfacing as a bean somewhere downstream that could not be assembled because of it.
|
||||
*
|
||||
* <p>Two jobs, both scoped to the master switch being on:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>Refuse a retired safety key.</b> See {@link GraphQlRetiredSafetyAxis}.
|
||||
* <li><b>Give the framework's console flags the platform's defaults.</b> Spring Boot answers
|
||||
* introspection by default; this platform does not. Both are defensible on their own and
|
||||
* together they meant that turning GraphQL on failed at startup on a contradiction nobody had
|
||||
* configured — the platform said introspection was off, the framework said it was on, and the
|
||||
* runtime validator correctly refused a deployment with two answers to one question. The
|
||||
* platform's value is contributed at the lowest precedence, so an operator who sets either
|
||||
* key still wins and still gets validated; what is removed is the disagreement that existed
|
||||
* with nothing set at all.
|
||||
* </ol>
|
||||
*
|
||||
* <p>Ordered last so profile-specific configuration data has already been contributed. A check that
|
||||
* ran earlier would read {@code application-prod.yml}'s keys as absent, which is the same as not
|
||||
* checking at all for the deployments that matter most.
|
||||
*/
|
||||
public class GraphQlActivationEnvironmentPostProcessor
|
||||
implements EnvironmentPostProcessor, Ordered {
|
||||
|
||||
/** Framework key to the platform key it defaults from. */
|
||||
private static final Map<String, String> FRAMEWORK_DEFAULTS =
|
||||
Map.of(
|
||||
"spring.graphql.schema.introspection.enabled",
|
||||
"backend.graphql.console.introspection-enabled",
|
||||
"spring.graphql.graphiql.enabled", "backend.graphql.console.graphiql-enabled");
|
||||
|
||||
private static final String SOURCE_NAME = "graphQlPlatformConsoleDefaults";
|
||||
|
||||
@Override
|
||||
public void postProcessEnvironment(
|
||||
ConfigurableEnvironment environment, SpringApplication application) {
|
||||
List<String> problems = GraphQlRetiredSafetyAxis.problems(environment);
|
||||
if (!problems.isEmpty()) {
|
||||
throw new IllegalStateException(String.join(System.lineSeparator(), problems));
|
||||
}
|
||||
if (!"true".equalsIgnoreCase(environment.getProperty("backend.graphql.enabled", "false"))) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> defaults = new LinkedHashMap<>();
|
||||
FRAMEWORK_DEFAULTS.forEach(
|
||||
(frameworkKey, platformKey) -> {
|
||||
if (!environment.containsProperty(frameworkKey)) {
|
||||
defaults.put(frameworkKey, environment.getProperty(platformKey, "false"));
|
||||
}
|
||||
});
|
||||
if (!defaults.isEmpty()) {
|
||||
environment.getPropertySources().addLast(new MapPropertySource(SOURCE_NAME, defaults));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.LOWEST_PRECEDENCE;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
/**
|
||||
* The one axis that decides how safely this deployment behaves (design §17, GQL-INT-002).
|
||||
*
|
||||
* <p>This used to be two settings. A {@code production} boolean drove anonymous principal handling,
|
||||
* allow-by-default authorization and part of request protection; an {@code environment} enum drove
|
||||
* introspection and GraphiQL. They defaulted to {@code false} and {@code PRODUCTION_PUBLIC}
|
||||
* respectively, in the same record, so the shipped default described an internet-facing production
|
||||
* endpoint whose protections behaved as if it were a laptop. Two axes that can disagree about the
|
||||
* same question are not two settings; they are one setting and a bug.
|
||||
*
|
||||
* <p>Introspection is a convenience in development and an attack aid in production: it hands an
|
||||
* anonymous caller the full type system, including the fields nobody advertises. GraphiQL
|
||||
* additionally serves a query console. Neither substitutes for authorization, and neither belongs
|
||||
* on a public production endpoint.
|
||||
*
|
||||
* <p>There is deliberately no default and no lenient parse. An unset mode is a startup error, not a
|
||||
* guess: a guess that lands on development is an unauthorized endpoint, and a guess that lands on
|
||||
* production is an outage an operator cannot explain.
|
||||
*
|
||||
* <p>These four are exactly the values a shipped runtime can select — one per runtime profile, with
|
||||
* production split by exposure. A {@code TEST} and a {@code STAGING} constant used to sit alongside
|
||||
* them with no profile that could reach either, which is a posture nobody can deploy and nobody can
|
||||
* test; {@code GraphQlDeploymentModeRegistryParityTest} keeps this list and the operator-facing
|
||||
* registry from drifting apart again.
|
||||
*/
|
||||
public enum GraphQlDeploymentMode {
|
||||
|
||||
/** Developer machine. */
|
||||
LOCAL(true, true, false),
|
||||
|
||||
/** Shared development environment. */
|
||||
DEV(true, true, false),
|
||||
|
||||
/** Production behind an organisational boundary. */
|
||||
PRODUCTION_INTERNAL(true, false, true),
|
||||
|
||||
/** Internet-facing production. */
|
||||
PRODUCTION_PUBLIC(false, false, true);
|
||||
|
||||
/** What an operator has to set, and where, when the mode is missing. */
|
||||
public static final String REQUIRED_MESSAGE =
|
||||
"backend.graphql.deployment-mode is required while backend.graphql.enabled=true; set "
|
||||
+ "APP_GRAPHQL_DEPLOYMENT_MODE to one of LOCAL, DEV, PRODUCTION_INTERNAL, "
|
||||
+ "PRODUCTION_PUBLIC";
|
||||
|
||||
private final boolean introspectionAllowed;
|
||||
private final boolean graphiqlAllowed;
|
||||
private final boolean production;
|
||||
|
||||
GraphQlDeploymentMode(boolean introspectionAllowed, boolean graphiqlAllowed, boolean production) {
|
||||
this.introspectionAllowed = introspectionAllowed;
|
||||
this.graphiqlAllowed = graphiqlAllowed;
|
||||
this.production = production;
|
||||
}
|
||||
|
||||
/** Whether introspection may be enabled at all in this mode. */
|
||||
public boolean introspectionAllowed() {
|
||||
return introspectionAllowed;
|
||||
}
|
||||
|
||||
/** Whether GraphiQL may be served. */
|
||||
public boolean graphiqlAllowed() {
|
||||
return graphiqlAllowed;
|
||||
}
|
||||
|
||||
/** Whether production rules apply. */
|
||||
public boolean production() {
|
||||
return production;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
|
||||
import org.springframework.context.EnvironmentAware;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Keeps Spring GraphQL's own auto-configurations out of the candidate set while the master is off.
|
||||
*
|
||||
* <p>Conditioning this repository's configuration is not enough. The GraphQL starter contributes
|
||||
* its auto-configurations through Boot's import metadata, so an ordinary
|
||||
* {@code @EnableAutoConfiguration} application publishes {@code /graphql} from the classpath alone,
|
||||
* whatever any project condition says. That is the difference between an endpoint that is off and
|
||||
* one whose project beans are absent while the framework serves it anyway.
|
||||
*
|
||||
* <p>A misspelled entry fails open silently — the filter simply never matches — so the test that
|
||||
* protects this asserts a 404 on the real port rather than checking what this method returns.
|
||||
*/
|
||||
public final class GraphQlOffAutoConfigurationImportFilter
|
||||
implements AutoConfigurationImportFilter, EnvironmentAware {
|
||||
|
||||
private static final String ENABLE_PROPERTY = "backend.graphql.enabled";
|
||||
|
||||
private static final Set<String> GRAPHQL_AUTO_CONFIGURATIONS =
|
||||
Set.of(
|
||||
"org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.observation.GraphQlObservationAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.data.GraphQlQuerydslAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.data.GraphQlReactiveQuerydslAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.rsocket.GraphQlRSocketAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.rsocket.RSocketGraphQlClientAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.reactive.GraphQlWebFluxAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.reactive.GraphQlWebFluxSecurityAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.servlet.GraphQlWebMvcAutoConfiguration",
|
||||
"org.springframework.boot.graphql.autoconfigure.servlet.GraphQlWebMvcSecurityAutoConfiguration");
|
||||
|
||||
private Environment environment;
|
||||
|
||||
@Override
|
||||
public boolean[] match(String[] candidates, AutoConfigurationMetadata metadata) {
|
||||
boolean enabled =
|
||||
environment != null
|
||||
&& "true".equalsIgnoreCase(environment.getProperty(ENABLE_PROPERTY, "false"));
|
||||
boolean[] matches = new boolean[candidates.length];
|
||||
for (int index = 0; index < candidates.length; index++) {
|
||||
matches[index] =
|
||||
enabled
|
||||
|| candidates[index] == null
|
||||
|| !GRAPHQL_AUTO_CONFIGURATIONS.contains(candidates[index]);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnvironment(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -20,7 +20,7 @@ public final class GraphQlPlatformActuatorEndpoint {
|
||||
/** Endpoint id a composition root should register this under. */
|
||||
public static final String ENDPOINT_ID = "graphqlPlatform";
|
||||
|
||||
private final GraphQlPlatformProperties properties;
|
||||
private final GraphQlPlatformSettings properties;
|
||||
private final GraphQlSchemaHash schemaHash;
|
||||
private final Set<String> supportedCapabilities;
|
||||
private final int registeredOperations;
|
||||
@@ -36,7 +36,7 @@ public final class GraphQlPlatformActuatorEndpoint {
|
||||
* @param registeredFetchProfiles count of registered fetch profiles
|
||||
*/
|
||||
public GraphQlPlatformActuatorEndpoint(
|
||||
GraphQlPlatformProperties properties,
|
||||
GraphQlPlatformSettings properties,
|
||||
GraphQlSchemaHash schemaHash,
|
||||
Set<String> supportedCapabilities,
|
||||
int registeredOperations,
|
||||
@@ -56,7 +56,7 @@ public final class GraphQlPlatformActuatorEndpoint {
|
||||
return new GraphQlPlatformConfigurationReport(
|
||||
schemaHash.value(),
|
||||
properties.executionProfile().name(),
|
||||
properties.environment().name(),
|
||||
properties.deploymentMode() == null ? "UNSET" : properties.deploymentMode().name(),
|
||||
GraphQlHttpProfile.V1.name(),
|
||||
supportedCapabilities,
|
||||
properties.cursor().keyIds(),
|
||||
|
||||
+34
-20
@@ -48,12 +48,9 @@ import java.time.Clock;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration;
|
||||
import org.springframework.boot.graphql.autoconfigure.GraphQlProperties;
|
||||
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
@@ -81,8 +78,7 @@ import org.springframework.util.ClassUtils;
|
||||
* adopter's beans existed and silently failed to back off. Without the ordering, this would race
|
||||
* the framework's own {@code GraphQlSource} and schema beans.
|
||||
*/
|
||||
@AutoConfiguration(after = GraphQlAutoConfiguration.class)
|
||||
@EnableConfigurationProperties(GraphQlPlatformProperties.class)
|
||||
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
|
||||
public class GraphQlPlatformAutoConfiguration {
|
||||
|
||||
/** Client profile applied to a caller with no verified credential. */
|
||||
@@ -118,7 +114,7 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
*/
|
||||
@Bean
|
||||
public InitializingBean graphQlPlatformConfigurationCheck(
|
||||
GraphQlPlatformProperties properties,
|
||||
GraphQlPlatformSettings properties,
|
||||
GraphQlPlatformStartupValidator validator,
|
||||
GraphQlExecutionPipeline pipeline,
|
||||
GraphQlScalarWiringConfigurer scalarWiring,
|
||||
@@ -276,9 +272,18 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
return chain.pipeline();
|
||||
}
|
||||
|
||||
/** The clock every deadline and expiry check reads. */
|
||||
/**
|
||||
* The clock every deadline and expiry check reads, supplied only if the application has none.
|
||||
*
|
||||
* <p>Conditioned on the type rather than on this bean's name. Conditioned on the name it always
|
||||
* created one, so an application that already had a {@code Clock} ended up with two and every
|
||||
* injection point that wanted one failed to start — which is what happened the first time this
|
||||
* platform shipped in the same context as the rest of the application, and could not have
|
||||
* happened while it was build-only. An application's own bean wins; that is what
|
||||
* auto-configuration is for.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "graphQlPlatformClock")
|
||||
@ConditionalOnMissingBean(Clock.class)
|
||||
public Clock graphQlPlatformClock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
@@ -292,7 +297,7 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformProperties properties) {
|
||||
public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformSettings properties) {
|
||||
return GraphQlClientPolicy.defaults(
|
||||
properties.limits().maximumPageSize(),
|
||||
properties.limits().maximumComplexity(),
|
||||
@@ -326,19 +331,28 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
* <p>There is no safe default here, so production does not get one. Coordinate rules are
|
||||
* application knowledge: a deny-by-default skeleton policy would answer nothing and adopters
|
||||
* would replace it with an allow-all one, while an allow-by-default policy shipped into
|
||||
* production would be an unauthorized endpoint. So development gets the permissive default that
|
||||
* lets the schema be explored, and {@code backend.graphql.production=true} refuses to start
|
||||
* without an explicit policy.
|
||||
* production would be an unauthorized endpoint. So a development mode gets the permissive default
|
||||
* that lets the schema be explored, and a production {@code backend.graphql.deployment-mode}
|
||||
* refuses to start without an explicit policy.
|
||||
*
|
||||
* <p>Which mode that is comes from one setting. While the posture was split across a boolean and
|
||||
* an enum, this bean read only the boolean — so a deployment declaring {@code PRODUCTION_PUBLIC}
|
||||
* still received the permissive default.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlAuthorizationPolicy graphQlAuthorizationPolicy(
|
||||
GraphQlPlatformProperties properties) {
|
||||
public GraphQlAuthorizationPolicy graphQlAuthorizationPolicy(GraphQlPlatformSettings properties) {
|
||||
if (properties.deploymentMode() == null) {
|
||||
throw new GraphQlPlatformConfigurationException(
|
||||
List.of(GraphQlDeploymentMode.REQUIRED_MESSAGE));
|
||||
}
|
||||
if (properties.production()) {
|
||||
throw new GraphQlPlatformConfigurationException(
|
||||
List.of(
|
||||
"backend.graphql.production=true requires an explicit GraphQlAuthorizationPolicy "
|
||||
+ "bean; the platform has no application coordinates to authorize on its own"));
|
||||
"backend.graphql.deployment-mode="
|
||||
+ properties.deploymentMode()
|
||||
+ " requires an explicit GraphQlAuthorizationPolicy bean; the platform has no "
|
||||
+ "application coordinates to authorize on its own"));
|
||||
}
|
||||
return GraphQlAuthorizationPolicy.builder().denyByDefault(false).build();
|
||||
}
|
||||
@@ -446,7 +460,7 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
GraphQlAuthenticationContextFactory contextFactory,
|
||||
GraphQlClientPolicy clientPolicy,
|
||||
GraphQlJsonStructurePolicy structurePolicy,
|
||||
GraphQlPlatformProperties properties,
|
||||
GraphQlPlatformSettings properties,
|
||||
Clock graphQlPlatformClock) {
|
||||
return new GraphQlPlatformWebInterceptor(
|
||||
principalResolver,
|
||||
@@ -522,7 +536,7 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlOperationNameCardinality graphQlOperationNameCardinality(
|
||||
GraphQlPlatformProperties properties) {
|
||||
GraphQlPlatformSettings properties) {
|
||||
return new GraphQlOperationNameCardinality(properties.observedOperationNames());
|
||||
}
|
||||
|
||||
@@ -551,7 +565,7 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlPreparsedCachePolicy graphQlPreparsedCachePolicy(
|
||||
GraphQlPlatformProperties properties) {
|
||||
GraphQlPlatformSettings properties) {
|
||||
return new GraphQlPreparsedCachePolicy(
|
||||
properties.limits().preparsedCacheEntries(),
|
||||
properties.limits().preparsedCacheWeight(),
|
||||
@@ -579,7 +593,7 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
public GraphQlSourceBuilderCustomizer graphQlPreparsedDocumentCustomizer(
|
||||
BoundedPreparsedDocumentProvider<PreparsedDocumentEntry> cache,
|
||||
ObjectProvider<GraphQlSource> graphQlSource,
|
||||
GraphQlPlatformProperties properties) {
|
||||
GraphQlPlatformSettings properties) {
|
||||
GraphQlPreparsedDocumentAdapter adapter =
|
||||
new GraphQlPreparsedDocumentAdapter(
|
||||
cache, () -> schemaContractHash(graphQlSource), properties.validationPolicyVersion());
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ import java.util.TreeSet;
|
||||
*
|
||||
* @param schemaHash hash of the deployed schema
|
||||
* @param executionProfile active execution profile
|
||||
* @param environment active environment
|
||||
* @param deploymentMode the single safety posture this deployment runs under
|
||||
* @param httpProfile transport profile version
|
||||
* @param supportedCapabilities capability names that are active
|
||||
* @param cursorKeyIds signing key identities, without the keys
|
||||
@@ -24,7 +24,7 @@ import java.util.TreeSet;
|
||||
public record GraphQlPlatformConfigurationReport(
|
||||
String schemaHash,
|
||||
String executionProfile,
|
||||
String environment,
|
||||
String deploymentMode,
|
||||
String httpProfile,
|
||||
Set<String> supportedCapabilities,
|
||||
Set<String> cursorKeyIds,
|
||||
@@ -44,7 +44,7 @@ public record GraphQlPlatformConfigurationReport(
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("schemaHash", schemaHash);
|
||||
report.put("executionProfile", executionProfile);
|
||||
report.put("environment", environment);
|
||||
report.put("deploymentMode", deploymentMode);
|
||||
report.put("httpProfile", httpProfile);
|
||||
report.put("supportedCapabilities", supportedCapabilities);
|
||||
report.put("cursorKeyIds", cursorKeyIds);
|
||||
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Environment-dependent introspection and GraphiQL policy (design §17).
|
||||
*
|
||||
* <p>Introspection is a convenience in development and an attack aid in production: it hands an
|
||||
* anonymous caller the full type system, including the fields nobody advertises. GraphiQL
|
||||
* additionally serves a query console. Neither substitutes for authorization, and neither belongs
|
||||
* on a public production endpoint.
|
||||
*/
|
||||
public enum GraphQlPlatformEnvironment {
|
||||
|
||||
/** Developer machine. */
|
||||
LOCAL(true, true, false),
|
||||
|
||||
/** Automated test environment. */
|
||||
TEST(true, false, false),
|
||||
|
||||
/** Shared development environment. */
|
||||
DEV(true, true, false),
|
||||
|
||||
/** Pre-production. */
|
||||
STAGING(true, false, true),
|
||||
|
||||
/** Production behind an organisational boundary. */
|
||||
PRODUCTION_INTERNAL(true, false, true),
|
||||
|
||||
/** Internet-facing production. */
|
||||
PRODUCTION_PUBLIC(false, false, true);
|
||||
|
||||
private final boolean introspectionAllowed;
|
||||
private final boolean graphiqlAllowed;
|
||||
private final boolean production;
|
||||
|
||||
GraphQlPlatformEnvironment(
|
||||
boolean introspectionAllowed, boolean graphiqlAllowed, boolean production) {
|
||||
this.introspectionAllowed = introspectionAllowed;
|
||||
this.graphiqlAllowed = graphiqlAllowed;
|
||||
this.production = production;
|
||||
}
|
||||
|
||||
/** Whether introspection may be enabled at all in this environment. */
|
||||
public boolean introspectionAllowed() {
|
||||
return introspectionAllowed;
|
||||
}
|
||||
|
||||
/** Whether GraphiQL may be served. */
|
||||
public boolean graphiqlAllowed() {
|
||||
return graphiqlAllowed;
|
||||
}
|
||||
|
||||
/** Whether production rules apply. */
|
||||
public boolean production() {
|
||||
return production;
|
||||
}
|
||||
|
||||
/** Resolves an environment name, defaulting to the strictest interpretation. */
|
||||
public static GraphQlPlatformEnvironment of(String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
return PRODUCTION_PUBLIC;
|
||||
}
|
||||
try {
|
||||
return valueOf(name.toUpperCase(Locale.ROOT).replace('-', '_'));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return PRODUCTION_PUBLIC;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ import java.util.Objects;
|
||||
* framework properties are not on the classpath
|
||||
*/
|
||||
public record GraphQlPlatformRuntime(
|
||||
GraphQlPlatformProperties properties,
|
||||
GraphQlPlatformSettings properties,
|
||||
GraphQlExecutionPipeline pipeline,
|
||||
GraphQlScalarWiringConfigurer scalarWiring,
|
||||
GraphQlClientPolicy clientPolicy,
|
||||
|
||||
+60
-37
@@ -20,8 +20,13 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
* or automatic repository exposure should fail at startup with a clear reason — silently ignoring
|
||||
* the setting would leave an operator believing it took effect.
|
||||
*
|
||||
* @param production whether production rules apply
|
||||
* @param environment environment governing introspection and GraphiQL
|
||||
* <p>{@code deploymentMode} is the exception to the defaults rule: it has none, and null survives
|
||||
* construction so {@link GraphQlPlatformStartupValidator} can refuse the startup by name. It
|
||||
* replaced a {@code production} boolean and an {@code environment} enum that defaulted to {@code
|
||||
* false} and {@code PRODUCTION_PUBLIC} in the same record — a default that claimed to be an
|
||||
* internet-facing production endpoint while every protection reading the boolean behaved otherwise.
|
||||
*
|
||||
* @param deploymentMode the single safety posture; required, see {@link GraphQlDeploymentMode}
|
||||
* @param executionProfile runtime execution profile
|
||||
* @param console query console and introspection exposure
|
||||
* @param limits page and cost ceilings
|
||||
@@ -30,9 +35,8 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
* @param unbridgedBlockingResolvers resolvers that block without an approved bridge
|
||||
*/
|
||||
@ConfigurationProperties("backend.graphql")
|
||||
public record GraphQlPlatformProperties(
|
||||
@DefaultValue("false") boolean production,
|
||||
@DefaultValue("PRODUCTION_PUBLIC") GraphQlPlatformEnvironment environment,
|
||||
public record GraphQlPlatformSettings(
|
||||
GraphQlDeploymentMode deploymentMode,
|
||||
@DefaultValue("BLOCKING_MVC") GraphQlExecutionProfile executionProfile,
|
||||
@DefaultValue Console console,
|
||||
@DefaultValue Limits limits,
|
||||
@@ -42,10 +46,10 @@ public record GraphQlPlatformProperties(
|
||||
@DefaultValue Set<String> observedOperationNames,
|
||||
@DefaultValue Set<String> unbridgedBlockingResolvers) {
|
||||
|
||||
public GraphQlPlatformProperties {
|
||||
public GraphQlPlatformSettings {
|
||||
// Belt and braces for programmatic construction: the binder honours @DefaultValue, but this
|
||||
// record is also built directly in tests and by adopters composing a policy in Java.
|
||||
environment = environment == null ? GraphQlPlatformEnvironment.PRODUCTION_PUBLIC : environment;
|
||||
// deploymentMode is deliberately not defaulted here — see the class Javadoc.
|
||||
executionProfile =
|
||||
executionProfile == null ? GraphQlExecutionProfile.BLOCKING_MVC : executionProfile;
|
||||
console = console == null ? Console.disabled() : console;
|
||||
@@ -167,11 +171,22 @@ public record GraphQlPlatformProperties(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether production rules apply, which is the deployment mode's answer and nothing else.
|
||||
*
|
||||
* <p>Fail-closed while the mode is absent. The startup validator refuses that configuration by
|
||||
* name, so this value is only ever read on the way to that refusal — and an unknown posture that
|
||||
* resolved to development would be an unauthorized endpoint for exactly as long as it took the
|
||||
* context to notice.
|
||||
*/
|
||||
public boolean production() {
|
||||
return deploymentMode == null || deploymentMode.production();
|
||||
}
|
||||
|
||||
/** Safe production defaults. */
|
||||
public static GraphQlPlatformProperties productionDefaults() {
|
||||
return new GraphQlPlatformProperties(
|
||||
true,
|
||||
GraphQlPlatformEnvironment.PRODUCTION_INTERNAL,
|
||||
public static GraphQlPlatformSettings productionDefaults() {
|
||||
return new GraphQlPlatformSettings(
|
||||
GraphQlDeploymentMode.PRODUCTION_INTERNAL,
|
||||
GraphQlExecutionProfile.BLOCKING_MVC,
|
||||
Console.disabled(),
|
||||
Limits.defaults(),
|
||||
@@ -182,21 +197,34 @@ public record GraphQlPlatformProperties(
|
||||
Set.of());
|
||||
}
|
||||
|
||||
/** Returns a copy running under a different deployment mode. */
|
||||
public GraphQlPlatformSettings withDeploymentMode(GraphQlDeploymentMode mode) {
|
||||
return new GraphQlPlatformSettings(
|
||||
mode,
|
||||
executionProfile,
|
||||
console,
|
||||
limits,
|
||||
cursor,
|
||||
unsupported,
|
||||
validationPolicyVersion,
|
||||
observedOperationNames,
|
||||
unbridgedBlockingResolvers);
|
||||
}
|
||||
|
||||
/** Returns a copy with GraphiQL enabled or disabled. */
|
||||
public GraphQlPlatformProperties withGraphiqlEnabled(boolean enabled) {
|
||||
public GraphQlPlatformSettings withGraphiqlEnabled(boolean enabled) {
|
||||
return withConsole(new Console(enabled, console.introspectionEnabled()));
|
||||
}
|
||||
|
||||
/** Returns a copy with introspection enabled or disabled. */
|
||||
public GraphQlPlatformProperties withIntrospectionEnabled(boolean enabled) {
|
||||
public GraphQlPlatformSettings withIntrospectionEnabled(boolean enabled) {
|
||||
return withConsole(new Console(console.graphiqlEnabled(), enabled));
|
||||
}
|
||||
|
||||
/** Returns a copy with a different console policy. */
|
||||
public GraphQlPlatformProperties withConsole(Console replacement) {
|
||||
return new GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
public GraphQlPlatformSettings withConsole(Console replacement) {
|
||||
return new GraphQlPlatformSettings(
|
||||
deploymentMode,
|
||||
executionProfile,
|
||||
replacement,
|
||||
limits,
|
||||
@@ -208,10 +236,9 @@ public record GraphQlPlatformProperties(
|
||||
}
|
||||
|
||||
/** Returns a copy with different limits. */
|
||||
public GraphQlPlatformProperties withLimits(Limits replacement) {
|
||||
return new GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
public GraphQlPlatformSettings withLimits(Limits replacement) {
|
||||
return new GraphQlPlatformSettings(
|
||||
deploymentMode,
|
||||
executionProfile,
|
||||
console,
|
||||
replacement,
|
||||
@@ -223,10 +250,9 @@ public record GraphQlPlatformProperties(
|
||||
}
|
||||
|
||||
/** Returns a copy with different cursor signing keys. */
|
||||
public GraphQlPlatformProperties withCursorKeyIds(Set<String> keyIds) {
|
||||
return new GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
public GraphQlPlatformSettings withCursorKeyIds(Set<String> keyIds) {
|
||||
return new GraphQlPlatformSettings(
|
||||
deploymentMode,
|
||||
executionProfile,
|
||||
console,
|
||||
limits,
|
||||
@@ -238,10 +264,9 @@ public record GraphQlPlatformProperties(
|
||||
}
|
||||
|
||||
/** Returns a copy with one unsupported capability toggled, for startup-validation tests. */
|
||||
public GraphQlPlatformProperties withUnsupportedCapability(String capability, boolean enabled) {
|
||||
return new GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
public GraphQlPlatformSettings withUnsupportedCapability(String capability, boolean enabled) {
|
||||
return new GraphQlPlatformSettings(
|
||||
deploymentMode,
|
||||
executionProfile,
|
||||
console,
|
||||
limits,
|
||||
@@ -253,10 +278,9 @@ public record GraphQlPlatformProperties(
|
||||
}
|
||||
|
||||
/** Returns a copy declaring resolvers that block without an approved bridge. */
|
||||
public GraphQlPlatformProperties withUnbridgedBlockingResolvers(Set<String> coordinates) {
|
||||
return new GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
public GraphQlPlatformSettings withUnbridgedBlockingResolvers(Set<String> coordinates) {
|
||||
return new GraphQlPlatformSettings(
|
||||
deploymentMode,
|
||||
executionProfile,
|
||||
console,
|
||||
limits,
|
||||
@@ -268,10 +292,9 @@ public record GraphQlPlatformProperties(
|
||||
}
|
||||
|
||||
/** Returns a copy running a different execution profile. */
|
||||
public GraphQlPlatformProperties withExecutionProfile(GraphQlExecutionProfile profile) {
|
||||
return new GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
public GraphQlPlatformSettings withExecutionProfile(GraphQlExecutionProfile profile) {
|
||||
return new GraphQlPlatformSettings(
|
||||
deploymentMode,
|
||||
profile,
|
||||
console,
|
||||
limits,
|
||||
+12
-10
@@ -19,7 +19,7 @@ public final class GraphQlPlatformStartupValidator {
|
||||
*
|
||||
* @throws GraphQlPlatformConfigurationException listing every problem found
|
||||
*/
|
||||
public void validate(GraphQlPlatformProperties properties) {
|
||||
public void validate(GraphQlPlatformSettings properties) {
|
||||
List<String> problems = problems(properties);
|
||||
if (!problems.isEmpty()) {
|
||||
throw new GraphQlPlatformConfigurationException(problems);
|
||||
@@ -27,22 +27,24 @@ public final class GraphQlPlatformStartupValidator {
|
||||
}
|
||||
|
||||
/** Every configuration problem, in a deterministic order. */
|
||||
public List<String> problems(GraphQlPlatformProperties properties) {
|
||||
public List<String> problems(GraphQlPlatformSettings properties) {
|
||||
List<String> problems = new ArrayList<>();
|
||||
|
||||
// Reported once even when both the production flag and the environment forbid it, so a single
|
||||
// misconfiguration does not appear as two problems.
|
||||
if (properties.console().graphiqlEnabled()
|
||||
&& (properties.production() || !properties.environment().graphiqlAllowed())) {
|
||||
problems.add(
|
||||
"GraphiQL must not be enabled in " + properties.environment() + " or in production");
|
||||
// First and alone. Every check below asks the mode a question, so reporting them against an
|
||||
// absent mode would bury the one problem an operator can act on under its own consequences.
|
||||
if (properties.deploymentMode() == null) {
|
||||
return List.of(GraphQlDeploymentMode.REQUIRED_MESSAGE);
|
||||
}
|
||||
|
||||
if (properties.console().graphiqlEnabled() && !properties.deploymentMode().graphiqlAllowed()) {
|
||||
problems.add("GraphiQL must not be enabled in " + properties.deploymentMode());
|
||||
}
|
||||
if (properties.production() && properties.cursor().keyIds().isEmpty()) {
|
||||
problems.add("a cursor signing key is required; unsigned cursors are client-editable");
|
||||
}
|
||||
if (properties.console().introspectionEnabled()
|
||||
&& !properties.environment().introspectionAllowed()) {
|
||||
problems.add("introspection is not permitted in " + properties.environment());
|
||||
&& !properties.deploymentMode().introspectionAllowed()) {
|
||||
problems.add("introspection is not permitted in " + properties.deploymentMode());
|
||||
}
|
||||
if (properties.limits().maximumPageSize() < 1) {
|
||||
problems.add("maximum page size must be positive");
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* The two retired safety keys, refused rather than ignored (GQL-INT-002).
|
||||
*
|
||||
* <p>{@code backend.graphql.production} and {@code backend.graphql.environment} are no longer
|
||||
* record components, and Spring's binder ignores unknown keys by default. Left alone, an operator
|
||||
* who sets the key they have always set gets a clean startup and a silently different safety
|
||||
* posture — which is a worse outcome than the split-brain being fixed, because the old
|
||||
* configuration at least did something.
|
||||
*
|
||||
* <p>Checked whether or not the replacement is also set. A deployment carrying both is exactly the
|
||||
* state that produced the defect: two keys that can disagree about one question, with the winner
|
||||
* decided by whichever code path happens to read which.
|
||||
*
|
||||
* <p>Only checked while the master switch is on. An adapter that is off has no configuration
|
||||
* surface to be wrong about, and a repository-wide property left over in someone's shell must not
|
||||
* stop an application that never publishes a GraphQL endpoint.
|
||||
*/
|
||||
public final class GraphQlRetiredSafetyAxis {
|
||||
|
||||
private static final Map<String, String> RETIRED_KEYS =
|
||||
Map.of(
|
||||
"backend.graphql.production",
|
||||
"it drove anonymous principal handling and allow-by-default authorization while the "
|
||||
+ "environment enum drove introspection, so the two could disagree",
|
||||
"backend.graphql.environment",
|
||||
"it drove introspection and GraphiQL while the production boolean drove authorization, "
|
||||
+ "so the two could disagree");
|
||||
|
||||
private GraphQlRetiredSafetyAxis() {}
|
||||
|
||||
/**
|
||||
* Returns one migration error per retired key that is still set, in a deterministic order.
|
||||
*
|
||||
* @param environment the resolved environment
|
||||
* @return the problems; empty when the master switch is off or no retired key is present
|
||||
*/
|
||||
public static List<String> problems(Environment environment) {
|
||||
if (!"true".equalsIgnoreCase(environment.getProperty("backend.graphql.enabled", "false"))) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> problems = new ArrayList<>();
|
||||
for (String key : RETIRED_KEYS.keySet().stream().sorted().toList()) {
|
||||
if (environment.containsProperty(key)) {
|
||||
problems.add(
|
||||
key
|
||||
+ " is retired ("
|
||||
+ RETIRED_KEYS.get(key)
|
||||
+ "). Remove it and set APP_GRAPHQL_DEPLOYMENT_MODE "
|
||||
+ "(backend.graphql.deployment-mode) to one of LOCAL, DEV, PRODUCTION_INTERNAL, "
|
||||
+ "PRODUCTION_PUBLIC.");
|
||||
}
|
||||
}
|
||||
return List.copyOf(problems);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/**
|
||||
* The one place that decides whether this application exposes a GraphQL endpoint.
|
||||
*
|
||||
* <p>The platform configuration this imports carried no master condition of any kind, so putting
|
||||
* the leaf on the classpath would have activated it — which is why the leaf was kept out of the
|
||||
* shipped jar entirely, and why "off" and "not built" had become the same thing.
|
||||
*
|
||||
* <p>An inbound adapter's off contract is not only about beans. Spring GraphQL publishes {@code
|
||||
* /graphql} from its own auto-configuration, with no project bean involved, so the companion import
|
||||
* filter has to keep those candidates out of the set as well. A bean-inventory assertion cannot see
|
||||
* a route the framework published.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true")
|
||||
@EnableConfigurationProperties(GraphQlPlatformSettings.class)
|
||||
@Import(GraphQlPlatformAutoConfiguration.class)
|
||||
public class GraphQlRootAutoConfiguration {}
|
||||
+2
-2
@@ -81,7 +81,7 @@ public final class GraphQlDocumentComplexityScorer {
|
||||
* @param operation the selected operation
|
||||
* @param variables the request variables, used to resolve page sizes
|
||||
* @throws GraphQlComplexityRejectedException when a requested page exceeds the maximum
|
||||
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
|
||||
* @throws GraphQlStructuralLimitException when traversal exceeds the node budget
|
||||
*/
|
||||
public GraphQlComplexityResult score(
|
||||
GraphQLSchema schema,
|
||||
@@ -288,7 +288,7 @@ public final class GraphQlDocumentComplexityScorer {
|
||||
|
||||
void visit(int budget) {
|
||||
if (++visited > budget) {
|
||||
throw GraphQlStructuralLimitViolation.of("COMPLEXITY_TRAVERSAL", visited, budget);
|
||||
throw GraphQlStructuralLimitException.of("COMPLEXITY_TRAVERSAL", visited, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -71,7 +71,7 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
* measurement: summing operations the request will not run charges a client for a document it
|
||||
* only sent one part of, and — worse in the other direction — averages away the one that matters.
|
||||
*
|
||||
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
|
||||
* @throws GraphQlStructuralLimitException when traversal exceeds the node budget
|
||||
*/
|
||||
public GraphQlDocumentShape analyze(Document document) {
|
||||
return analyze(document, null);
|
||||
@@ -82,7 +82,7 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
*
|
||||
* @param document the parsed document
|
||||
* @param operation the selected operation, or {@code null} to measure every operation
|
||||
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
|
||||
* @throws GraphQlStructuralLimitException when traversal exceeds the node budget
|
||||
*/
|
||||
public GraphQlDocumentShape analyze(Document document, OperationDefinition operation) {
|
||||
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
|
||||
@@ -158,7 +158,7 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
/**
|
||||
* Rejects introspection when the client profile does not allow it.
|
||||
*
|
||||
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
|
||||
* @throws GraphQlStructuralLimitException when introspection is selected but not permitted
|
||||
*/
|
||||
public void verifyIntrospection(Document document, boolean introspectionAllowed) {
|
||||
verifyIntrospection(document, null, introspectionAllowed);
|
||||
@@ -167,12 +167,12 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
/**
|
||||
* Rejects introspection reached by the selected operation.
|
||||
*
|
||||
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
|
||||
* @throws GraphQlStructuralLimitException when introspection is selected but not permitted
|
||||
*/
|
||||
public void verifyIntrospection(
|
||||
Document document, OperationDefinition operation, boolean introspectionAllowed) {
|
||||
if (!introspectionAllowed && selectsIntrospection(document, operation)) {
|
||||
throw GraphQlStructuralLimitViolation.of("INTROSPECTION", 1, 0);
|
||||
throw GraphQlStructuralLimitException.of("INTROSPECTION", 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
|
||||
void visit(int budget) {
|
||||
if (++visited > budget) {
|
||||
throw GraphQlStructuralLimitViolation.of("DOCUMENT_TRAVERSAL", visited, budget);
|
||||
throw GraphQlStructuralLimitException.of("DOCUMENT_TRAVERSAL", visited, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ package dev.caskeleton.adapter.inbound.graphql.cost;
|
||||
* <p>Names the dimension and the two counts so the client can see which budget was hit, without the
|
||||
* document appearing anywhere.
|
||||
*/
|
||||
public class GraphQlStructuralLimitViolation extends RuntimeException {
|
||||
public class GraphQlStructuralLimitException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GraphQlStructuralLimitViolation extends RuntimeException {
|
||||
|
||||
private final transient String limitName;
|
||||
|
||||
private GraphQlStructuralLimitViolation(String limitName, long observed, long allowed) {
|
||||
private GraphQlStructuralLimitException(String limitName, long observed, long allowed) {
|
||||
super(CODE + " " + limitName + ": " + observed + " > " + allowed);
|
||||
this.limitName = limitName;
|
||||
}
|
||||
@@ -27,8 +27,8 @@ public class GraphQlStructuralLimitViolation extends RuntimeException {
|
||||
* @param observed measured count
|
||||
* @param allowed configured ceiling
|
||||
*/
|
||||
public static GraphQlStructuralLimitViolation of(String limitName, long observed, long allowed) {
|
||||
return new GraphQlStructuralLimitViolation(limitName, observed, allowed);
|
||||
public static GraphQlStructuralLimitException of(String limitName, long observed, long allowed) {
|
||||
return new GraphQlStructuralLimitException(limitName, observed, allowed);
|
||||
}
|
||||
|
||||
/** The exceeded dimension's name. */
|
||||
+8
-8
@@ -29,32 +29,32 @@ public final class GraphQlStructuralLimitPolicy {
|
||||
/**
|
||||
* Verifies a measured document shape.
|
||||
*
|
||||
* @throws GraphQlStructuralLimitViolation on the first exceeded dimension
|
||||
* @throws GraphQlStructuralLimitException on the first exceeded dimension
|
||||
*/
|
||||
public void verify(GraphQlDocumentShape shape) {
|
||||
if (shape.aliasCount() > limits.maxAliases()) {
|
||||
throw GraphQlStructuralLimitViolation.of("ALIASES", shape.aliasCount(), limits.maxAliases());
|
||||
throw GraphQlStructuralLimitException.of("ALIASES", shape.aliasCount(), limits.maxAliases());
|
||||
}
|
||||
if (shape.depth() > limits.maxDepth()) {
|
||||
throw GraphQlStructuralLimitViolation.of("DEPTH", shape.depth(), limits.maxDepth());
|
||||
throw GraphQlStructuralLimitException.of("DEPTH", shape.depth(), limits.maxDepth());
|
||||
}
|
||||
if (shape.fieldCount() > limits.maxFields()) {
|
||||
throw GraphQlStructuralLimitViolation.of("FIELDS", shape.fieldCount(), limits.maxFields());
|
||||
throw GraphQlStructuralLimitException.of("FIELDS", shape.fieldCount(), limits.maxFields());
|
||||
}
|
||||
if (shape.fragmentCount() > limits.maxFragments()) {
|
||||
throw GraphQlStructuralLimitViolation.of(
|
||||
throw GraphQlStructuralLimitException.of(
|
||||
"FRAGMENTS", shape.fragmentCount(), limits.maxFragments());
|
||||
}
|
||||
if (shape.fragmentSpreadCount() > limits.maxFragmentSpreads()) {
|
||||
throw GraphQlStructuralLimitViolation.of(
|
||||
throw GraphQlStructuralLimitException.of(
|
||||
"FRAGMENT_SPREADS", shape.fragmentSpreadCount(), limits.maxFragmentSpreads());
|
||||
}
|
||||
if (shape.operationCount() > limits.maxOperations()) {
|
||||
throw GraphQlStructuralLimitViolation.of(
|
||||
throw GraphQlStructuralLimitException.of(
|
||||
"OPERATIONS", shape.operationCount(), limits.maxOperations());
|
||||
}
|
||||
if (shape.inputNestingDepth() > limits.maxInputNestingDepth()) {
|
||||
throw GraphQlStructuralLimitViolation.of(
|
||||
throw GraphQlStructuralLimitException.of(
|
||||
"INPUT_NESTING_DEPTH", shape.inputNestingDepth(), limits.maxInputNestingDepth());
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ package dev.caskeleton.adapter.inbound.graphql.release;
|
||||
*
|
||||
* <p>Names the missing evidence, so the answer to "why is this blocked" is in the message.
|
||||
*/
|
||||
public class GraphQlReleaseFailure extends RuntimeException {
|
||||
public class GraphQlReleaseException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -14,7 +14,7 @@ public class GraphQlReleaseFailure extends RuntimeException {
|
||||
*
|
||||
* @param message which evidence is missing
|
||||
*/
|
||||
public GraphQlReleaseFailure(String message) {
|
||||
public GraphQlReleaseException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -16,12 +16,12 @@ public final class GraphQlReleaseGate {
|
||||
/**
|
||||
* Verifies a release.
|
||||
*
|
||||
* @throws GraphQlReleaseFailure naming the missing evidence
|
||||
* @throws GraphQlReleaseException naming the missing evidence
|
||||
*/
|
||||
public void verify(GraphQlReleaseEvidence evidence) {
|
||||
List<String> missing = missing(evidence);
|
||||
if (!missing.isEmpty()) {
|
||||
throw new GraphQlReleaseFailure(
|
||||
throw new GraphQlReleaseException(
|
||||
"schema, contract, performance, fault and compatibility evidence are all required; missing: "
|
||||
+ missing);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public final class GraphQlReleaseGate {
|
||||
* @param evidence the collected evidence
|
||||
* @param override the audited override, or {@code null}
|
||||
* @param now current instant, used to reject an expired override
|
||||
* @throws GraphQlReleaseFailure when evidence is missing and no valid override covers it
|
||||
* @throws GraphQlReleaseException when evidence is missing and no valid override covers it
|
||||
*/
|
||||
public void verify(
|
||||
GraphQlReleaseEvidence evidence, GraphQlReleaseOverride override, Instant now) {
|
||||
@@ -67,11 +67,11 @@ public final class GraphQlReleaseGate {
|
||||
return;
|
||||
}
|
||||
if (!override.valid(now)) {
|
||||
throw new GraphQlReleaseFailure(
|
||||
throw new GraphQlReleaseException(
|
||||
"the release override has expired; missing evidence: " + missing);
|
||||
}
|
||||
if (!override.covers(missing)) {
|
||||
throw new GraphQlReleaseFailure(
|
||||
throw new GraphQlReleaseException(
|
||||
"the release override does not cover the missing evidence: " + missing);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -73,19 +73,19 @@ public final class GraphQlStableCapabilityManifest {
|
||||
/**
|
||||
* Verifies a capability may be activated on the Stable starter.
|
||||
*
|
||||
* @throws GraphQlReleaseFailure when it is Advanced, Experimental or unsupported
|
||||
* @throws GraphQlReleaseException when it is Advanced, Experimental or unsupported
|
||||
*/
|
||||
public static void requireStable(String capability) {
|
||||
if (STABLE.contains(capability)) {
|
||||
return;
|
||||
}
|
||||
if (UNSUPPORTED.contains(capability)) {
|
||||
throw new GraphQlReleaseFailure(capability + " is not supported by the GraphQL platform");
|
||||
throw new GraphQlReleaseException(capability + " is not supported by the GraphQL platform");
|
||||
}
|
||||
if (ADVANCED.contains(capability) || EXPERIMENTAL.contains(capability)) {
|
||||
throw new GraphQlReleaseFailure(
|
||||
throw new GraphQlReleaseException(
|
||||
capability + " requires the Advanced plan and an explicit feature flag");
|
||||
}
|
||||
throw new GraphQlReleaseFailure(capability + " is not a registered GraphQL capability");
|
||||
throw new GraphQlReleaseException(capability + " is not a registered GraphQL capability");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityRejectedException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitViolation;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode;
|
||||
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext;
|
||||
@@ -71,10 +71,10 @@ public final class GraphQlPlatformRejectionMapper {
|
||||
GraphQlErrorCategory.AUTHORIZATION,
|
||||
context);
|
||||
}
|
||||
if (failure instanceof GraphQlStructuralLimitViolation violation) {
|
||||
if (failure instanceof GraphQlStructuralLimitException violation) {
|
||||
return GraphQlWireError.of(
|
||||
"요청 문서가 허용된 구조 한계를 초과했습니다.",
|
||||
GraphQlErrorCode.of(GraphQlStructuralLimitViolation.CODE),
|
||||
GraphQlErrorCode.of(GraphQlStructuralLimitException.CODE),
|
||||
GraphQlErrorCategory.REQUEST,
|
||||
context)
|
||||
.withConstraint(violation.limitName());
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlOffAutoConfigurationImportFilter
|
||||
org.springframework.boot.EnvironmentPostProcessor=\
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlActivationEnvironmentPostProcessor
|
||||
+1
-1
@@ -1 +1 @@
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration
|
||||
|
||||
+7
@@ -48,6 +48,13 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
classes = GraphqlHttpBoundaryQualificationTest.TestApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {
|
||||
// The transport is off unless a deployment asks for it, so a boundary qualification has to
|
||||
// ask too. Before the master switch existed this test passed by accident: the endpoint was
|
||||
// published from the classpath.
|
||||
"backend.graphql.enabled=true",
|
||||
// Required alongside the switch: the safety posture has no default, because the defaults it
|
||||
// used to have were a production enum and a non-production boolean that disagreed.
|
||||
"backend.graphql.deployment-mode=LOCAL",
|
||||
"spring.graphql.graphiql.enabled=false",
|
||||
"spring.graphql.schema.introspection.enabled=false",
|
||||
"spring.graphql.schema.locations=classpath:graphql-qualification-no-discovery/",
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@ class GraphQlAdvancedReleaseGateTest {
|
||||
var evidence = new GraphQlAdvancedReleaseEvidence(false, true, true, true, true);
|
||||
|
||||
assertThatThrownBy(() -> new GraphQlAdvancedReleaseGate().verify(evidence))
|
||||
.isInstanceOf(GraphQlAdvancedReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlAdvancedReleaseException.class)
|
||||
.hasMessageContaining("stable");
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class GraphQlAdvancedReleaseGateTest {
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> gate.verify(new GraphQlAdvancedReleaseEvidence(true, true, true, false, true)))
|
||||
.isInstanceOf(GraphQlAdvancedReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlAdvancedReleaseException.class)
|
||||
.hasMessageContaining("soak");
|
||||
assertThat(gate.missing(new GraphQlAdvancedReleaseEvidence(true, false, false, false, false)))
|
||||
.containsExactly("capabilityContracts", "security", "soak", "compatibility");
|
||||
@@ -68,7 +68,7 @@ class GraphQlAdvancedReleaseGateTest {
|
||||
|
||||
assertThat(promotion.promotes()).isTrue();
|
||||
assertThatThrownBy(promotion::verify)
|
||||
.isInstanceOf(GraphQlAdvancedReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlAdvancedReleaseException.class)
|
||||
.hasMessageContaining("outstanding evidence");
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ class GraphQlAdvancedReleaseGateTest {
|
||||
assertThat(index.require(GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION))
|
||||
.isEqualTo("docs/runbooks/graphql-ws.md");
|
||||
assertThatThrownBy(() -> index.require(GraphQlAdvancedCapability.FEDERATION_SUBGRAPH))
|
||||
.isInstanceOf(GraphQlAdvancedReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlAdvancedReleaseException.class)
|
||||
.hasMessageContaining("runbook");
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -26,7 +26,7 @@ class GraphQlWebSocketProtocolTest {
|
||||
var lifecycle = new GraphQlWebSocketLifecycle(Duration.ofSeconds(5), CONNECTED_AT);
|
||||
|
||||
assertThatThrownBy(() -> lifecycle.onConnectionInit(Instant.parse("2026-08-12T00:00:06Z")))
|
||||
.isInstanceOf(GraphQlWebSocketProtocolError.class);
|
||||
.isInstanceOf(GraphQlWebSocketProtocolException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -38,20 +38,21 @@ class GraphQlWebSocketProtocolTest {
|
||||
() ->
|
||||
factory.requireSupportedSubProtocol(
|
||||
GraphQlWebSocketProtocol.UNSUPPORTED_LEGACY_SUB_PROTOCOL))
|
||||
.isInstanceOf(GraphQlWebSocketProtocolError.class);
|
||||
.isInstanceOf(GraphQlWebSocketProtocolException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeBeforeInitialisationIsAProtocolError() {
|
||||
var lifecycle = new GraphQlWebSocketLifecycle(Duration.ofSeconds(5), CONNECTED_AT);
|
||||
|
||||
assertThatThrownBy(lifecycle::onSubscribe).isInstanceOf(GraphQlWebSocketProtocolError.class);
|
||||
assertThatThrownBy(lifecycle::onSubscribe)
|
||||
.isInstanceOf(GraphQlWebSocketProtocolException.class);
|
||||
|
||||
lifecycle.onConnectionInit(CONNECTED_AT.plusSeconds(1));
|
||||
assertThatCode(lifecycle::onSubscribe).doesNotThrowAnyException();
|
||||
assertThat(lifecycle.activeSubscriptions()).isEqualTo(1);
|
||||
assertThatThrownBy(() -> lifecycle.onConnectionInit(CONNECTED_AT.plusSeconds(2)))
|
||||
.isInstanceOf(GraphQlWebSocketProtocolError.class);
|
||||
.isInstanceOf(GraphQlWebSocketProtocolException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -61,7 +62,7 @@ class GraphQlWebSocketProtocolTest {
|
||||
lifecycle.onSubscribe();
|
||||
|
||||
assertThatThrownBy(lifecycle::onSubscribe)
|
||||
.isInstanceOf(GraphQlWebSocketProtocolError.class)
|
||||
.isInstanceOf(GraphQlWebSocketProtocolException.class)
|
||||
.hasMessageContaining("maximum subscriptions");
|
||||
lifecycle.onComplete();
|
||||
assertThatCode(lifecycle::onSubscribe).doesNotThrowAnyException();
|
||||
@@ -77,7 +78,7 @@ class GraphQlWebSocketProtocolTest {
|
||||
lifecycle.startDraining();
|
||||
|
||||
assertThatThrownBy(lifecycle::onSubscribe)
|
||||
.isInstanceOf(GraphQlWebSocketProtocolError.class)
|
||||
.isInstanceOf(GraphQlWebSocketProtocolException.class)
|
||||
.hasMessageContaining("draining");
|
||||
}
|
||||
|
||||
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
|
||||
/**
|
||||
* One deployment mode decides every production-safety behaviour (GQL-INT-002).
|
||||
*
|
||||
* <p>There used to be two axes. {@code backend.graphql.production} defaulted to {@code false} and
|
||||
* {@code backend.graphql.environment} defaulted to {@code PRODUCTION_PUBLIC}, in the same record,
|
||||
* so the shipped default was a deployment that called itself internet-facing production while every
|
||||
* protection that read the boolean behaved as if it were not. That is not a display inconsistency:
|
||||
* anonymous principal handling, allow-by-default authorization, and part of request protection read
|
||||
* only the boolean, so the enum could say production while the endpoint answered as development.
|
||||
*
|
||||
* <p>The cases below are the contract that replaces it: the mode is required, it is the only source
|
||||
* of the production posture, and the two retired keys are a migration error rather than a setting
|
||||
* that is quietly ignored.
|
||||
*/
|
||||
class GraphQlDeploymentModeContractTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues("backend.graphql.enabled=true");
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(GraphQlDeploymentMode.class)
|
||||
@DisplayName("the production posture is the mode's, never a separate flag")
|
||||
void theProductionPostureIsAlwaysTheModes(GraphQlDeploymentMode mode) {
|
||||
GraphQlPlatformSettings settings =
|
||||
GraphQlPlatformSettings.productionDefaults().withDeploymentMode(mode);
|
||||
|
||||
assertThat(settings.production())
|
||||
.as(
|
||||
"%s declares production=%s; a settings object that could answer otherwise is the "
|
||||
+ "split-brain this collapse removes",
|
||||
mode, mode.production())
|
||||
.isEqualTo(mode.production());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an absent mode is refused, by name")
|
||||
void anAbsentModeIsRefusedByName() {
|
||||
GraphQlPlatformSettings settings =
|
||||
GraphQlPlatformSettings.productionDefaults().withDeploymentMode(null);
|
||||
|
||||
assertThat(new GraphQlPlatformStartupValidator().problems(settings))
|
||||
.as("a message that does not name the property sends an operator reading")
|
||||
.anySatisfy(problem -> assertThat(problem).contains("backend.graphql.deployment-mode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an absent mode reads as production until it is set")
|
||||
void anAbsentModeReadsAsProduction() {
|
||||
assertThat(GraphQlPlatformSettings.productionDefaults().withDeploymentMode(null).production())
|
||||
.as("an unknown posture that resolves to development is the failure mode, not the default")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the console policy follows the mode")
|
||||
void theConsolePolicyFollowsTheMode() {
|
||||
GraphQlPlatformStartupValidator validator = new GraphQlPlatformStartupValidator();
|
||||
|
||||
assertThat(
|
||||
validator.problems(
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withDeploymentMode(GraphQlDeploymentMode.LOCAL)
|
||||
.withGraphiqlEnabled(true)))
|
||||
.isEmpty();
|
||||
assertThat(
|
||||
validator.problems(
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withDeploymentMode(GraphQlDeploymentMode.PRODUCTION_PUBLIC)
|
||||
.withGraphiqlEnabled(true)))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("GraphiQL"));
|
||||
assertThat(
|
||||
validator.problems(
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withDeploymentMode(GraphQlDeploymentMode.PRODUCTION_PUBLIC)
|
||||
.withIntrospectionEnabled(true)))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("introspection"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"backend.graphql.production", "backend.graphql.environment"})
|
||||
@DisplayName("a retired safety key is a migration error naming the replacement")
|
||||
void aRetiredKeyIsAMigrationError(String retired) {
|
||||
List<String> problems =
|
||||
GraphQlRetiredSafetyAxis.problems(environmentWith(Map.of(retired, "true")));
|
||||
|
||||
assertThat(problems)
|
||||
.as("silently ignoring it leaves an operator believing a safety setting took effect")
|
||||
.anySatisfy(problem -> assertThat(problem).contains(retired))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("APP_GRAPHQL_DEPLOYMENT_MODE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a retired key alongside the new one is still a migration error")
|
||||
void aRetiredKeyAlongsideTheNewOneIsStillAnError() {
|
||||
List<String> problems =
|
||||
GraphQlRetiredSafetyAxis.problems(
|
||||
environmentWith(
|
||||
Map.of(
|
||||
"backend.graphql.deployment-mode", "LOCAL",
|
||||
"backend.graphql.environment", "PRODUCTION_PUBLIC")));
|
||||
|
||||
assertThat(problems)
|
||||
.as(
|
||||
"two keys that can disagree is the defect; the new one winning silently is the same bug")
|
||||
.isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("with the master off the retired keys are not read at all")
|
||||
void withTheMasterOffTheNamespaceIsNotValidated() {
|
||||
ConfigurableEnvironment environment =
|
||||
environmentWith(Map.of("backend.graphql.production", "true"));
|
||||
environment.getPropertySources().addFirst(off());
|
||||
|
||||
assertThat(GraphQlRetiredSafetyAxis.problems(environment))
|
||||
.as("an off adapter has no configuration surface to be wrong about")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the context refuses to start when the switch is on and no mode is set")
|
||||
void theContextRefusesToStartWithoutAMode() {
|
||||
runner.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasFailed()
|
||||
.getFailure()
|
||||
.rootCause()
|
||||
.hasMessageContaining("backend.graphql.deployment-mode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a development mode gets the permissive authorization default")
|
||||
void aDevelopmentModeGetsThePermissiveDefault() {
|
||||
runner
|
||||
.withPropertyValues("backend.graphql.deployment-mode=LOCAL")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context)
|
||||
.as("development exists so the schema can be explored without a coordinate table")
|
||||
.hasSingleBean(GraphQlAuthorizationPolicy.class);
|
||||
assertThat(context).hasSingleBean(GraphQlPlatformWebInterceptor.class);
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"PRODUCTION_INTERNAL", "PRODUCTION_PUBLIC"})
|
||||
@DisplayName("a production mode refuses the permissive default, naming the mode")
|
||||
void aProductionModeRefusesThePermissiveDefault(String mode) {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"backend.graphql.deployment-mode=" + mode,
|
||||
"backend.graphql.cursor.key-ids=cursor-key-1")
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasFailed()
|
||||
.getFailure()
|
||||
.rootCause()
|
||||
.hasMessageContaining("requires an explicit GraphQlAuthorizationPolicy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the mode reaches the interceptor that decides what an anonymous caller may do")
|
||||
void theModeReachesTheRequestPath() {
|
||||
runner
|
||||
.withPropertyValues("backend.graphql.deployment-mode=LOCAL")
|
||||
.run(
|
||||
context -> {
|
||||
GraphQlPlatformSettings settings = context.getBean(GraphQlPlatformSettings.class);
|
||||
|
||||
assertThat(settings.deploymentMode()).isEqualTo(GraphQlDeploymentMode.LOCAL);
|
||||
assertThat(settings.production())
|
||||
.as(
|
||||
"the interceptor is constructed from this value; when it disagreed with the "
|
||||
+ "environment enum the endpoint served production traffic under local rules")
|
||||
.isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the framework's console flags default to the platform's, not to Boot's")
|
||||
void theFrameworkConsoleFlagsFollowThePlatform() {
|
||||
ConfigurableEnvironment environment = environmentWith(Map.of());
|
||||
|
||||
new GraphQlActivationEnvironmentPostProcessor().postProcessEnvironment(environment, null);
|
||||
|
||||
assertThat(environment.getProperty("spring.graphql.schema.introspection.enabled"))
|
||||
.as(
|
||||
"Boot answers introspection by default and this platform does not, so turning GraphQL "
|
||||
+ "on used to fail the runtime validator on a contradiction nobody had configured")
|
||||
.isEqualTo("false");
|
||||
assertThat(environment.getProperty("spring.graphql.graphiql.enabled")).isEqualTo("false");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an operator who sets a framework flag still wins, and is still validated")
|
||||
void anExplicitFrameworkFlagIsNotOverwritten() {
|
||||
ConfigurableEnvironment environment =
|
||||
environmentWith(Map.of("spring.graphql.schema.introspection.enabled", "true"));
|
||||
|
||||
new GraphQlActivationEnvironmentPostProcessor().postProcessEnvironment(environment, null);
|
||||
|
||||
assertThat(environment.getProperty("spring.graphql.schema.introspection.enabled"))
|
||||
.as("the defaults are contributed at the lowest precedence; they never override a choice")
|
||||
.isEqualTo("true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the platform's console setting is what the framework is given")
|
||||
void thePlatformConsoleSettingReachesTheFramework() {
|
||||
ConfigurableEnvironment environment =
|
||||
environmentWith(Map.of("backend.graphql.console.introspection-enabled", "true"));
|
||||
|
||||
new GraphQlActivationEnvironmentPostProcessor().postProcessEnvironment(environment, null);
|
||||
|
||||
assertThat(environment.getProperty("spring.graphql.schema.introspection.enabled"))
|
||||
.as("an operator who enables the platform's console must not have to enable it twice")
|
||||
.isEqualTo("true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("with the master off no framework default is contributed")
|
||||
void withTheMasterOffNoFrameworkDefaultIsContributed() {
|
||||
ConfigurableEnvironment environment = environmentWith(Map.of());
|
||||
environment.getPropertySources().addFirst(off());
|
||||
|
||||
new GraphQlActivationEnvironmentPostProcessor().postProcessEnvironment(environment, null);
|
||||
|
||||
assertThat(environment.getPropertySources().contains("graphQlPlatformConsoleDefaults"))
|
||||
.as("an off adapter contributes nothing, including a default")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
private static ConfigurableEnvironment environmentWith(Map<String, String> properties) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(source("contract", properties));
|
||||
environment
|
||||
.getPropertySources()
|
||||
.addLast(source("master", Map.of("backend.graphql.enabled", "true")));
|
||||
return environment;
|
||||
}
|
||||
|
||||
private static MapPropertySource off() {
|
||||
return source("off", Map.of("backend.graphql.enabled", "false"));
|
||||
}
|
||||
|
||||
private static MapPropertySource source(String name, Map<String, String> properties) {
|
||||
return new MapPropertySource(name, new LinkedHashMap<>(properties));
|
||||
}
|
||||
}
|
||||
+27
-12
@@ -31,10 +31,21 @@ class GraphQlPlatformAutoConfigurationTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class));
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
// The root owns the master switch, so every case here has to turn it on. That is the
|
||||
// point of entering through the root rather than the platform configuration: a test that
|
||||
// could assemble the platform without the switch would be proving something the shipped
|
||||
// application cannot do.
|
||||
//
|
||||
// The deployment mode is here for the same reason: it is required, so a case that omits
|
||||
// it is testing the missing-mode refusal rather than whatever it meant to test. That
|
||||
// refusal has its own cases in GraphQlDeploymentModeContractTest. Later property values
|
||||
// override earlier ones, so a case that needs a different mode simply names it.
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL");
|
||||
|
||||
@Test
|
||||
void aContextWithNoPlatformPropertiesBootsWithSafeDefaults() {
|
||||
void aContextWithOnlyTheDeploymentModeSetBootsWithSafeDefaults() {
|
||||
runner.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
@@ -42,7 +53,7 @@ class GraphQlPlatformAutoConfigurationTest {
|
||||
assertThat(context).hasSingleBean(GraphQlPlatformInstrumentation.class);
|
||||
assertThat(context).hasSingleBean(GraphQlScalarWiringConfigurer.class);
|
||||
|
||||
GraphQlPlatformProperties properties = context.getBean(GraphQlPlatformProperties.class);
|
||||
GraphQlPlatformSettings properties = context.getBean(GraphQlPlatformSettings.class);
|
||||
assertThat(properties.limits().maximumPageSize()).isEqualTo(100);
|
||||
assertThat(properties.limits().maximumComplexity()).isEqualTo(10_000);
|
||||
assertThat(properties.console().graphiqlEnabled()).isFalse();
|
||||
@@ -71,8 +82,7 @@ class GraphQlPlatformAutoConfigurationTest {
|
||||
void productionRefusesToStartWithoutAnExplicitAuthorizationPolicy() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"backend.graphql.production=true",
|
||||
"backend.graphql.environment=PRODUCTION_INTERNAL",
|
||||
"backend.graphql.deployment-mode=PRODUCTION_INTERNAL",
|
||||
"backend.graphql.cursor.key-ids=cursor-key-1")
|
||||
.run(
|
||||
context ->
|
||||
@@ -129,7 +139,7 @@ class GraphQlPlatformAutoConfigurationTest {
|
||||
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"backend.graphql.environment=LOCAL",
|
||||
"backend.graphql.deployment-mode=LOCAL",
|
||||
"backend.graphql.console.introspection-enabled=true")
|
||||
.withBean(GraphQlProperties.class, () -> framework)
|
||||
.run(
|
||||
@@ -148,7 +158,7 @@ class GraphQlPlatformAutoConfigurationTest {
|
||||
framework.getGraphiql().setEnabled(true);
|
||||
|
||||
runner
|
||||
.withPropertyValues("backend.graphql.environment=LOCAL")
|
||||
.withPropertyValues("backend.graphql.deployment-mode=LOCAL")
|
||||
.withBean(GraphQlProperties.class, () -> framework)
|
||||
.run(
|
||||
context ->
|
||||
@@ -166,7 +176,7 @@ class GraphQlPlatformAutoConfigurationTest {
|
||||
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"backend.graphql.environment=LOCAL",
|
||||
"backend.graphql.deployment-mode=LOCAL",
|
||||
"backend.graphql.console.introspection-enabled=true")
|
||||
.withBean(GraphQlProperties.class, () -> framework)
|
||||
.run(context -> assertThat(context).hasNotFailed());
|
||||
@@ -179,8 +189,12 @@ class GraphQlPlatformAutoConfigurationTest {
|
||||
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports");
|
||||
|
||||
assertThat(imports)
|
||||
.as("an unregistered auto-configuration never applies to an adopter's context")
|
||||
.contains(GraphQlPlatformAutoConfiguration.class.getName());
|
||||
.as(
|
||||
"the registered entry is the master-gated root, not the platform configuration it "
|
||||
+ "imports; registering the platform directly is what let a context assemble a "
|
||||
+ "GraphQL endpoint that no switch had asked for")
|
||||
.contains(GraphQlRootAutoConfiguration.class.getName())
|
||||
.doesNotContain(GraphQlPlatformAutoConfiguration.class.getName() + "\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -188,8 +202,9 @@ class GraphQlPlatformAutoConfigurationTest {
|
||||
String metadata = readClasspathResource("META-INF/spring-configuration-metadata.json");
|
||||
|
||||
assertThat(metadata)
|
||||
.contains("backend.graphql.production")
|
||||
.contains("backend.graphql.environment")
|
||||
.contains("backend.graphql.deployment-mode")
|
||||
.doesNotContain("backend.graphql.production")
|
||||
.doesNotContain("backend.graphql.environment")
|
||||
.contains("backend.graphql.execution-profile")
|
||||
.contains("backend.graphql.console.graphiql-enabled")
|
||||
.contains("backend.graphql.console.introspection-enabled")
|
||||
|
||||
+15
-17
@@ -15,7 +15,7 @@ class GraphQlPlatformStartupValidatorTest {
|
||||
@Test
|
||||
void productionRejectsGraphiqlAndMissingCursorKey() {
|
||||
var properties =
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withGraphiqlEnabled(true)
|
||||
.withCursorKeyIds(Set.of());
|
||||
|
||||
@@ -28,14 +28,14 @@ class GraphQlPlatformStartupValidatorTest {
|
||||
assertThatCode(
|
||||
() ->
|
||||
new GraphQlPlatformStartupValidator()
|
||||
.validate(GraphQlPlatformProperties.productionDefaults()))
|
||||
.validate(GraphQlPlatformSettings.productionDefaults()))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyProblemIsReportedAtOnce() {
|
||||
var properties =
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withGraphiqlEnabled(true)
|
||||
.withCursorKeyIds(Set.of())
|
||||
.withUnsupportedCapability("multipart", true);
|
||||
@@ -49,27 +49,27 @@ class GraphQlPlatformStartupValidatorTest {
|
||||
|
||||
assertThat(
|
||||
validator.problems(
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withUnsupportedCapability("multipart", true)))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("Fileserver"));
|
||||
assertThat(
|
||||
validator.problems(
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withUnsupportedCapability("arrayBatch", true)))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("array batching"));
|
||||
assertThat(
|
||||
validator.problems(
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withUnsupportedCapability("requestWideTransaction", true)))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("request-wide database transactions"));
|
||||
assertThat(
|
||||
validator.problems(
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withUnsupportedCapability("repositoryAutoExposure", true)))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("automatic repository exposure"));
|
||||
assertThat(
|
||||
validator.problems(
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withUnsupportedCapability("responseCache", true)))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("response caching"));
|
||||
}
|
||||
@@ -79,7 +79,7 @@ class GraphQlPlatformStartupValidatorTest {
|
||||
assertThat(
|
||||
new GraphQlPlatformStartupValidator()
|
||||
.problems(
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withUnsupportedCapability("advancedOnStableStarter", true)))
|
||||
.anySatisfy(problem -> assertThat(problem).contains("Advanced capabilities"));
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class GraphQlPlatformStartupValidatorTest {
|
||||
@Test
|
||||
void aBlockingResolverWithoutABridgeCannotRunOnTheReactiveProfile() {
|
||||
var properties =
|
||||
GraphQlPlatformProperties.productionDefaults()
|
||||
GraphQlPlatformSettings.productionDefaults()
|
||||
.withExecutionProfile(GraphQlExecutionProfile.REACTIVE_WEBFLUX)
|
||||
.withUnbridgedBlockingResolvers(Set.of("Order.total"));
|
||||
|
||||
@@ -96,19 +96,17 @@ class GraphQlPlatformStartupValidatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void introspectionAndGraphiqlFollowTheEnvironmentPolicy() {
|
||||
assertThat(GraphQlPlatformEnvironment.of("local").graphiqlAllowed()).isTrue();
|
||||
assertThat(GraphQlPlatformEnvironment.of("staging").graphiqlAllowed()).isFalse();
|
||||
assertThat(GraphQlPlatformEnvironment.of("production_public").introspectionAllowed()).isFalse();
|
||||
assertThat(GraphQlPlatformEnvironment.of("unknown-environment"))
|
||||
.isEqualTo(GraphQlPlatformEnvironment.PRODUCTION_PUBLIC);
|
||||
void introspectionAndGraphiqlFollowTheDeploymentMode() {
|
||||
assertThat(GraphQlDeploymentMode.LOCAL.graphiqlAllowed()).isTrue();
|
||||
assertThat(GraphQlDeploymentMode.PRODUCTION_INTERNAL.graphiqlAllowed()).isFalse();
|
||||
assertThat(GraphQlDeploymentMode.PRODUCTION_PUBLIC.introspectionAllowed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOperationsReportDisclosesHashesAndCapabilitiesOnly() {
|
||||
var endpoint =
|
||||
new GraphQlPlatformActuatorEndpoint(
|
||||
GraphQlPlatformProperties.productionDefaults(),
|
||||
GraphQlPlatformSettings.productionDefaults(),
|
||||
GraphQlSchemaHash.sha256("type Query { ping: String! }"),
|
||||
Set.of("HTTP_POST", "CURSOR_CONNECTION"),
|
||||
12,
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyInterceptor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec;
|
||||
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyRing;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
/**
|
||||
* Which policies are on the request path, and which only look as though they are (GQL-INT-003).
|
||||
*
|
||||
* <p>Operation selection, authorization and cost budgeting are genuinely wired: they are the three
|
||||
* handlers of {@code GraphQlExecutionChain}, reached through the platform's {@code
|
||||
* Instrumentation}, and {@code GraphQlPlatformExecutionPathTest} proves each of them rejects on a
|
||||
* real random-port request before any resolver runs. This file does not repeat that.
|
||||
*
|
||||
* <p>It records the other half, which no test asserted and which reads as working from every angle
|
||||
* an operator can see:
|
||||
*
|
||||
* <p><b>Cursor signing is not on the request path.</b> {@code backend.graphql.cursor.key-ids} is
|
||||
* consumed in exactly two places — {@link GraphQlPlatformStartupValidator}, which refuses to start
|
||||
* a production deployment without it, and {@link GraphQlPlatformActuatorEndpoint}, which reports it
|
||||
* back. Nothing signs a cursor with it. {@code HmacGraphQlCursorCodec} and {@link
|
||||
* GraphQlCursorKeyRing} exist and are unit-tested; neither is ever constructed by the platform. So
|
||||
* production demands a key identity, an operator supplies one, the endpoint confirms it is
|
||||
* configured — and cursors remain exactly as client-editable as they were, which is the thing the
|
||||
* validator's own message says the key prevents.
|
||||
*
|
||||
* <p><b>Mutation idempotency is not on the request path either.</b> {@link
|
||||
* GraphQlMutationIdempotencyInterceptor} is referenced by no configuration.
|
||||
*
|
||||
* <p>Both are honest {@code modelled} capabilities by this leaf's own grading table — the defect is
|
||||
* not that they are unfinished, it is that a startup validator makes one of them look finished.
|
||||
* Closing it is a design decision rather than a wiring one: {@link GraphQlCursorKeyRing#of} takes
|
||||
* {@code Map<String, byte[]>} and the settings deliberately say the keys themselves never appear in
|
||||
* configuration, so where the key material comes from has to be decided before anything can be
|
||||
* wired. Until it is, this test fails the moment somebody wires one half without the other.
|
||||
*/
|
||||
class GraphQlPolicyRequestPathTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL");
|
||||
|
||||
@Test
|
||||
@DisplayName("the policies that are wired reach the request path through the instrumentation")
|
||||
void theWiredPoliciesAreOnTheRequestPath() {
|
||||
runner.run(
|
||||
context -> {
|
||||
assertThat(context)
|
||||
.as(
|
||||
"Spring for GraphQL picks up every Instrumentation bean; this is what turns the "
|
||||
+ "policy objects from a catalogue into something a request has to pass")
|
||||
.hasSingleBean(GraphQlPlatformInstrumentation.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a configured cursor key signs nothing, because no codec is ever built")
|
||||
void aConfiguredCursorKeySignsNothing() {
|
||||
runner
|
||||
.withPropertyValues("backend.graphql.cursor.key-ids=cursor-key-1")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
|
||||
assertThat(context.getBean(GraphQlPlatformSettings.class).cursor().keyIds())
|
||||
.as("the operator supplied it and the settings carry it")
|
||||
.containsExactly("cursor-key-1");
|
||||
assertThat(context)
|
||||
.as(
|
||||
"and nothing signs with it: production refuses to start without a key that "
|
||||
+ "protects nothing, which is worse than not asking for one")
|
||||
.doesNotHaveBean(GraphQlCursorCodec.class);
|
||||
assertThat(context).doesNotHaveBean(GraphQlCursorKeyRing.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mutation idempotency is modelled, not applied")
|
||||
void mutationIdempotencyIsNotApplied() {
|
||||
runner.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.as(
|
||||
"no configuration references the interceptor, so a repeated mutation is a "
|
||||
+ "repeated mutation")
|
||||
.doesNotHaveBean(GraphQlMutationIdempotencyInterceptor.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the startup validator still demands the key it cannot make anyone use")
|
||||
void theValidatorStillDemandsTheUnusedKey() {
|
||||
GraphQlPlatformSettings production =
|
||||
GraphQlPlatformSettings.productionDefaults().withCursorKeyIds(Set.of());
|
||||
|
||||
assertThat(new GraphQlPlatformStartupValidator().problems(production))
|
||||
.as(
|
||||
"kept, not removed: the demand is right and the missing half is the implementation. "
|
||||
+ "When cursor signing reaches the request path, the two cases above invert and "
|
||||
+ "this one stays exactly as it is.")
|
||||
.anySatisfy(problem -> assertThat(problem).contains("cursor signing key"));
|
||||
}
|
||||
}
|
||||
+22
-11
@@ -64,16 +64,20 @@ class GraphQlTransportNeutralityTest {
|
||||
@Test
|
||||
void aNonWebContextAcceptsAnyProfile() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
|
||||
.withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX")
|
||||
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.execution-profile=REACTIVE_WEBFLUX")
|
||||
.run(context -> assertThat(context).hasNotFailed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aServletContextRunningTheReactiveProfileRefusesToStart() {
|
||||
new WebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
|
||||
.withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX")
|
||||
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.execution-profile=REACTIVE_WEBFLUX")
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
@@ -86,8 +90,10 @@ class GraphQlTransportNeutralityTest {
|
||||
@Test
|
||||
void aReactiveContextRunningTheBlockingProfileRefusesToStart() {
|
||||
new ReactiveWebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
|
||||
.withPropertyValues("backend.graphql.execution-profile=BLOCKING_MVC")
|
||||
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.execution-profile=BLOCKING_MVC")
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
@@ -100,8 +106,10 @@ class GraphQlTransportNeutralityTest {
|
||||
@Test
|
||||
void aServletContextRunningTheBlockingProfileStarts() {
|
||||
new WebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
|
||||
.withPropertyValues("backend.graphql.execution-profile=BLOCKING_MVC")
|
||||
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.execution-profile=BLOCKING_MVC")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
@@ -113,8 +121,10 @@ class GraphQlTransportNeutralityTest {
|
||||
@Test
|
||||
void aReactiveContextRunningTheReactiveProfileStarts() {
|
||||
new ReactiveWebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
|
||||
.withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX")
|
||||
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"backend.graphql.enabled=true", "backend.graphql.execution-profile=REACTIVE_WEBFLUX")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
@@ -144,7 +154,8 @@ class GraphQlTransportNeutralityTest {
|
||||
@Test
|
||||
void aReactiveContextWithUnbridgedBlockingResolversRefusesToStart() {
|
||||
new ReactiveWebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
|
||||
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
|
||||
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"backend.graphql.execution-profile=MIXED_CONTROLLED",
|
||||
"backend.graphql.unbridged-blocking-resolvers=Order.total")
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class GraphQlFragmentReachabilityTest {
|
||||
.as("the fragment reaches __schema, so the document selects introspection")
|
||||
.isTrue();
|
||||
assertThatThrownBy(() -> analyzer.verifyIntrospection(document, false))
|
||||
.isInstanceOf(GraphQlStructuralLimitViolation.class)
|
||||
.isInstanceOf(GraphQlStructuralLimitException.class)
|
||||
.hasMessageContaining("INTROSPECTION");
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -17,7 +17,7 @@ class GraphQlStructuralLimitPolicyTest {
|
||||
var limits = new GraphQlStructuralLimits(8, 100, 10, 20, 40, 2, 8);
|
||||
|
||||
assertThatThrownBy(() -> new GraphQlStructuralLimitPolicy(limits).verify(shape))
|
||||
.isInstanceOf(GraphQlStructuralLimitViolation.class)
|
||||
.isInstanceOf(GraphQlStructuralLimitException.class)
|
||||
.hasMessageContaining("ALIASES");
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class GraphQlStructuralLimitPolicyTest {
|
||||
var analyzer = new GraphQlDocumentShapeAnalyzer(3);
|
||||
|
||||
assertThatThrownBy(() -> analyzer.analyze("query Wide { a b c d e f }"))
|
||||
.isInstanceOf(GraphQlStructuralLimitViolation.class)
|
||||
.isInstanceOf(GraphQlStructuralLimitException.class)
|
||||
.hasMessageContaining("DOCUMENT_TRAVERSAL");
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class GraphQlStructuralLimitPolicyTest {
|
||||
|
||||
assertThat(analyzer.selectsIntrospection(document)).isTrue();
|
||||
assertThatThrownBy(() -> analyzer.verifyIntrospection(document, false))
|
||||
.isInstanceOf(GraphQlStructuralLimitViolation.class)
|
||||
.isInstanceOf(GraphQlStructuralLimitException.class)
|
||||
.hasMessageContaining("INTROSPECTION");
|
||||
assertThatCode(() -> analyzer.verifyIntrospection(document, true)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
+6
-6
@@ -27,7 +27,7 @@ class GraphQlReleaseGateTest {
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> new GraphQlReleaseGate().verify(evidence))
|
||||
.isInstanceOf(GraphQlReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlReleaseException.class)
|
||||
.hasMessageContaining("fault");
|
||||
}
|
||||
|
||||
@@ -79,10 +79,10 @@ class GraphQlReleaseGateTest {
|
||||
"release-manager", "incident", Set.of("fault"), NOW.plus(Duration.ofDays(1)));
|
||||
|
||||
assertThatThrownBy(() -> gate.verify(incomplete, expired, NOW))
|
||||
.isInstanceOf(GraphQlReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlReleaseException.class)
|
||||
.hasMessageContaining("expired");
|
||||
assertThatThrownBy(() -> gate.verify(incomplete, wrongScope, NOW))
|
||||
.isInstanceOf(GraphQlReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlReleaseException.class)
|
||||
.hasMessageContaining("does not cover");
|
||||
assertThatCode(() -> gate.verify(incomplete, valid, NOW)).doesNotThrowAnyException();
|
||||
}
|
||||
@@ -115,14 +115,14 @@ class GraphQlReleaseGateTest {
|
||||
assertThat(GraphQlStableCapabilityManifest.stable("HTTP_POST")).isTrue();
|
||||
assertThatThrownBy(
|
||||
() -> GraphQlStableCapabilityManifest.requireStable("GRAPHQL_MULTIPART_UPLOAD"))
|
||||
.isInstanceOf(GraphQlReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlReleaseException.class)
|
||||
.hasMessageContaining("not supported");
|
||||
assertThatThrownBy(
|
||||
() -> GraphQlStableCapabilityManifest.requireStable("WEBSOCKET_SUBSCRIPTION"))
|
||||
.isInstanceOf(GraphQlReleaseFailure.class)
|
||||
.isInstanceOf(GraphQlReleaseException.class)
|
||||
.hasMessageContaining("Advanced plan");
|
||||
assertThatThrownBy(() -> GraphQlStableCapabilityManifest.requireStable("MADE_UP"))
|
||||
.isInstanceOf(GraphQlReleaseFailure.class);
|
||||
.isInstanceOf(GraphQlReleaseException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -55,6 +55,8 @@ import org.springframework.stereotype.Controller;
|
||||
classes = GraphQlPlatformExecutionPathTest.TestApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {
|
||||
// The transport is off unless a deployment asks for it, so an execution-path test has to ask.
|
||||
"backend.graphql.enabled=true",
|
||||
"spring.graphql.graphiql.enabled=false",
|
||||
// The schema permits introspection at the deployment level, and the two flags agree so the
|
||||
// contradiction check stays quiet. What refuses the query is the anonymous profile's client
|
||||
@@ -65,7 +67,7 @@ import org.springframework.stereotype.Controller;
|
||||
"spring.graphql.schema.additional-files="
|
||||
+ "classpath:graphql/skeleton.graphqls,"
|
||||
+ "classpath:graphql-platform/platform.graphqls",
|
||||
"backend.graphql.environment=LOCAL",
|
||||
"backend.graphql.deployment-mode=LOCAL",
|
||||
"backend.graphql.console.introspection-enabled=true",
|
||||
"backend.graphql.limits.maximum-page-size=100",
|
||||
// Small enough that one nested selection exceeds it, large enough that the allowed queries do
|
||||
|
||||
+22
-2
@@ -7,6 +7,7 @@ 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.ObjectProvider;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -26,11 +27,30 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
@EnableMethodSecurity(prePostEnabled = false)
|
||||
public class MethodSecurityConfig {
|
||||
|
||||
/**
|
||||
* The advisor, taking the port as a provider rather than as a value.
|
||||
*
|
||||
* <p>An advisor is infrastructure and is created while {@code BeanPostProcessor}s are still being
|
||||
* registered. Taking {@code AuthorizationPort} directly made Spring instantiate it — and with it
|
||||
* {@code RolePermissionRegistry} and the bound {@code RolePermissionPolicy} — inside that window,
|
||||
* and every startup logged three {@code BeanPostProcessorChecker} warnings saying those beans
|
||||
* were not eligible for post-processing. They were not eligible, which is the actual problem: a
|
||||
* bean created that early skips the post-processors that would otherwise apply to it.
|
||||
*
|
||||
* <p>Deferred, not silenced. Marking the chain {@code ROLE_INFRASTRUCTURE} removes the warning
|
||||
* and keeps the early instantiation; resolving through {@link ObjectProvider} on first
|
||||
* authorization instead means the port is built normally, after the container is ready. This is
|
||||
* the same fix {@code TracingConfig} uses for {@code Tracer}.
|
||||
*
|
||||
* @param authorizationPort the port, resolved on first use
|
||||
* @return the advisor
|
||||
*/
|
||||
@Bean
|
||||
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||
static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) {
|
||||
static Advisor requiresPermissionAuthorizationAdvisor(
|
||||
ObjectProvider<AuthorizationPort> authorizationPort) {
|
||||
AuthorizationManager<MethodInvocation> manager =
|
||||
new RequiresPermissionAuthorizationManager(authorizationPort);
|
||||
new RequiresPermissionAuthorizationManager(authorizationPort::getObject);
|
||||
|
||||
Pointcut onMethod = AnnotationMatchingPointcut.forMethodAnnotation(RequiresPermission.class);
|
||||
Pointcut onClass = AnnotationMatchingPointcut.forClassAnnotation(RequiresPermission.class);
|
||||
|
||||
+11
-3
@@ -29,9 +29,17 @@ import org.springframework.security.core.Authentication;
|
||||
public final class RequiresPermissionAuthorizationManager
|
||||
implements AuthorizationManager<MethodInvocation> {
|
||||
|
||||
private final AuthorizationPort authorizationPort;
|
||||
private final Supplier<AuthorizationPort> authorizationPort;
|
||||
|
||||
public RequiresPermissionAuthorizationManager(AuthorizationPort authorizationPort) {
|
||||
/**
|
||||
* Creates the manager.
|
||||
*
|
||||
* @param authorizationPort the port, resolved on first authorization rather than at construction;
|
||||
* an advisor is built while BeanPostProcessors are still registering, and resolving the port
|
||||
* there instantiates it — and its whole role/permission chain — too early to be
|
||||
* post-processed
|
||||
*/
|
||||
public RequiresPermissionAuthorizationManager(Supplier<AuthorizationPort> authorizationPort) {
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
@@ -49,7 +57,7 @@ public final class RequiresPermissionAuthorizationManager
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
try {
|
||||
authorizationPort.requirePermission(toPrincipal(auth), required);
|
||||
authorizationPort.get().requirePermission(toPrincipal(auth), required);
|
||||
return new AuthorizationDecision(true);
|
||||
} catch (AuthorizationDeniedException denied) {
|
||||
return new AuthorizationDecision(false);
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.port.in.AcceptNotificationCommand;
|
||||
import dev.caskeleton.application.notification.platform.port.in.AcceptNotificationUseCase;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Submitting a notification (NTF-INT-008).
|
||||
*
|
||||
* <p>The platform's only submission surface. Until this existed it had none: every use case, store,
|
||||
* worker, template registry and provider was in place, and nothing outside the process could ask it
|
||||
* to send anything. The callback endpoint was the single notification path a deployment exposed,
|
||||
* and that one is for providers reporting outcomes back — it cannot originate a notification.
|
||||
*
|
||||
* <p>{@code 202}, not {@code 201}. A receipt means the request and its recipient jobs are committed
|
||||
* and a worker will pick them up; no provider has been contacted while the transaction was open, so
|
||||
* answering {@code 201 Created} with a location would promise a resource whose delivery has not
|
||||
* been attempted. The platform's own contract is that acceptance and delivery are different events.
|
||||
*
|
||||
* <p>Authenticated like every other non-public path, and authorized by {@code notification:submit}
|
||||
* on the use case rather than here. A transport that decided its own permissions would be a second
|
||||
* authorization model to keep in step with the first.
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.notification.platform",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@RequestMapping("/notifications")
|
||||
public class NotificationSubmissionController {
|
||||
|
||||
/** What a caller gets when it names no version; the first published version of a template. */
|
||||
private static final long DEFAULT_TEMPLATE_VERSION = 1L;
|
||||
|
||||
/** What a caller gets when it names no category. */
|
||||
private static final String DEFAULT_CATEGORY = "transactional";
|
||||
|
||||
private final AcceptNotificationUseCase accept;
|
||||
|
||||
/**
|
||||
* Creates the controller.
|
||||
*
|
||||
* @param accept the use case that registers the contact point and submits
|
||||
*/
|
||||
public NotificationSubmissionController(AcceptNotificationUseCase accept) {
|
||||
this.accept = Objects.requireNonNull(accept, "accept");
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts one notification for dispatch.
|
||||
*
|
||||
* @param request the submission
|
||||
* @return {@code 202} with the receipt
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<Envelope<SubmitNotificationHttpResponse>> submit(
|
||||
@Valid @RequestBody SubmitNotificationHttpRequest request) {
|
||||
|
||||
AcceptNotificationCommand command =
|
||||
new AcceptNotificationCommand(
|
||||
Optional.empty(),
|
||||
request.recipientRef(),
|
||||
channel(request.channel()),
|
||||
request.address(),
|
||||
request.template(),
|
||||
request.templateVersion() == null
|
||||
? DEFAULT_TEMPLATE_VERSION
|
||||
: request.templateVersion(),
|
||||
locale(request.locale()),
|
||||
NotificationVariableCoercion.coerceAll(request.variables()),
|
||||
Optional.ofNullable(request.idempotencyKey()).filter(key -> !key.isBlank()),
|
||||
request.category() == null || request.category().isBlank()
|
||||
? DEFAULT_CATEGORY
|
||||
: request.category());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
||||
.body(
|
||||
Envelope.ok(
|
||||
SubmitNotificationHttpResponse.from(accept.handle(command)),
|
||||
ResponseMetaFactory.fromMdc()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a caller named.
|
||||
*
|
||||
* <p>Parsed here rather than bound by Jackson so an unknown name fails as a validation error
|
||||
* naming the vocabulary, instead of as a deserialization error naming a Java enum.
|
||||
*/
|
||||
private static Channel channel(String name) {
|
||||
try {
|
||||
return Channel.valueOf(name.trim().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException unknown) {
|
||||
throw new IllegalArgumentException(
|
||||
"channel must be one of " + java.util.Arrays.toString(Channel.values()), unknown);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale a caller named, or the platform default.
|
||||
*
|
||||
* <p>{@link Locale#forLanguageTag} answers {@code Locale.ROOT} for anything it cannot parse
|
||||
* rather than failing, so an unparseable tag is refused explicitly — silently rendering a Korean
|
||||
* notification in the root locale is the kind of wrong that reaches a recipient.
|
||||
*/
|
||||
private static Locale locale(String tag) {
|
||||
if (tag == null || tag.isBlank()) {
|
||||
return Locale.getDefault();
|
||||
}
|
||||
Locale parsed = Locale.forLanguageTag(tag.trim());
|
||||
if (parsed.equals(Locale.ROOT)) {
|
||||
throw new IllegalArgumentException("locale must be a BCP 47 language tag");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateCommand;
|
||||
import dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateUseCase;
|
||||
import dev.caskeleton.application.notification.platform.template.TemplateSlot;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Publishing a notification template version (NTF-INT-008).
|
||||
*
|
||||
* <p>A submission pins a template id and version, and the platform refuses one it cannot resolve.
|
||||
* So this endpoint is not a convenience next to {@link NotificationSubmissionController} — without
|
||||
* it every submission fails, which is what a deployment found: the registry port had {@code
|
||||
* publish} and no application or transport ever called it.
|
||||
*
|
||||
* <p>{@code 201}, unlike a submission's {@code 202}: a published version is an immutable resource
|
||||
* that exists when the call returns, with nothing deferred to a worker.
|
||||
*
|
||||
* <p>Authorized by {@code notification-template:publish}, which is deliberately not {@code
|
||||
* notification:submit}. Publishing a template changes what every future submission renders, and a
|
||||
* service that may send notifications is not thereby a service that may rewrite them.
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.notification.platform",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@RequestMapping("/notification-templates")
|
||||
public class NotificationTemplateController {
|
||||
|
||||
private final PublishNotificationTemplateUseCase publish;
|
||||
|
||||
/**
|
||||
* Creates the controller.
|
||||
*
|
||||
* @param publish the publication use case
|
||||
*/
|
||||
public NotificationTemplateController(PublishNotificationTemplateUseCase publish) {
|
||||
this.publish = Objects.requireNonNull(publish, "publish");
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes one immutable template version.
|
||||
*
|
||||
* @param request the version to publish
|
||||
* @return {@code 201} with the published version and the digest the platform computed
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<Envelope<PublishTemplateHttpResponse>> publish(
|
||||
@Valid @RequestBody PublishTemplateHttpRequest request) {
|
||||
|
||||
PublishNotificationTemplateCommand command =
|
||||
new PublishNotificationTemplateCommand(
|
||||
request.templateId(),
|
||||
request.version() == null ? 1L : request.version(),
|
||||
channel(request.channel()),
|
||||
locale(request.locale()),
|
||||
Optional.ofNullable(request.fallbackLocale())
|
||||
.filter(tag -> !tag.isBlank())
|
||||
.map(NotificationTemplateController::locale),
|
||||
slots(request.slots()),
|
||||
names(request.requiredVariables()),
|
||||
names(request.sensitiveVariables()));
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
Envelope.ok(
|
||||
PublishTemplateHttpResponse.from(publish.handle(command)),
|
||||
ResponseMetaFactory.fromMdc()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The slots a caller named.
|
||||
*
|
||||
* <p>An unknown slot name is refused rather than dropped. Silently ignoring it publishes a
|
||||
* template missing the content the caller wrote, and the failure then arrives at render time as a
|
||||
* missing required slot for a template that looks published and complete.
|
||||
*/
|
||||
private static Map<TemplateSlot, String> slots(Map<String, String> source) {
|
||||
Map<TemplateSlot, String> slots = new EnumMap<>(TemplateSlot.class);
|
||||
source.forEach(
|
||||
(name, content) -> {
|
||||
TemplateSlot slot;
|
||||
try {
|
||||
slot = TemplateSlot.valueOf(name.trim().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException unknown) {
|
||||
throw new IllegalArgumentException(
|
||||
"slot must be one of " + java.util.Arrays.toString(TemplateSlot.values()), unknown);
|
||||
}
|
||||
slots.put(slot, content);
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
private static Set<String> names(List<String> source) {
|
||||
return source == null ? Set.of() : Set.copyOf(source);
|
||||
}
|
||||
|
||||
private static Channel channel(String name) {
|
||||
try {
|
||||
return Channel.valueOf(name.trim().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException unknown) {
|
||||
throw new IllegalArgumentException(
|
||||
"channel must be one of " + java.util.Arrays.toString(Channel.values()), unknown);
|
||||
}
|
||||
}
|
||||
|
||||
private static Locale locale(String tag) {
|
||||
Locale parsed = Locale.forLanguageTag(tag.trim());
|
||||
if (parsed.equals(Locale.ROOT)) {
|
||||
throw new IllegalArgumentException("locale must be a BCP 47 language tag");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationVariable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Turns a parsed JSON value into the platform's closed variable algebra.
|
||||
*
|
||||
* <p>The coercion exists because {@code NotificationVariable} is not {@code Object}, and that is a
|
||||
* deliberate property rather than a formality: the request fingerprint is computed over variables,
|
||||
* and when they were {@code Object} the fingerprint rendered them through {@code toString()} — so
|
||||
* {@code "1"} and {@code 1} hashed alike and two different requests could collapse onto one
|
||||
* idempotency key. Coercing at the transport boundary is what keeps that distinction from depending
|
||||
* on a JSON parser's choice of box type.
|
||||
*
|
||||
* <p>Integral JSON numbers arrive as {@code Integer} or {@code Long} and floating point ones as
|
||||
* {@code Double}; all three become {@link BigDecimal} through their decimal text, never through
|
||||
* {@code BigDecimal.valueOf(double)}, which would turn {@code 0.1} into its binary approximation
|
||||
* and make the fingerprint depend on IEEE 754 rather than on what the caller wrote.
|
||||
*
|
||||
* <p>An unsupported type is refused rather than stringified. A silent {@code toString()} is how a
|
||||
* caller's nested structure would become the literal text of a Java object reference inside a
|
||||
* rendered notification.
|
||||
*/
|
||||
final class NotificationVariableCoercion {
|
||||
|
||||
private NotificationVariableCoercion() {}
|
||||
|
||||
/**
|
||||
* Coerces a parsed JSON object into platform variables.
|
||||
*
|
||||
* @param source the parsed body's variable map, possibly null
|
||||
* @return the variables, empty when the caller supplied none
|
||||
*/
|
||||
static Map<String, NotificationVariable> coerceAll(Map<String, Object> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, NotificationVariable> coerced = new LinkedHashMap<>(source.size());
|
||||
source.forEach((name, value) -> coerced.put(name, coerce(name, value)));
|
||||
return Map.copyOf(coerced);
|
||||
}
|
||||
|
||||
private static NotificationVariable coerce(String name, Object value) {
|
||||
return switch (value) {
|
||||
case null -> NotificationVariable.NullValue.INSTANCE;
|
||||
case String text -> new NotificationVariable.TextValue(text);
|
||||
case Boolean flag -> new NotificationVariable.BooleanValue(flag);
|
||||
case BigDecimal number -> new NotificationVariable.NumberValue(number);
|
||||
case Number number -> new NotificationVariable.NumberValue(new BigDecimal(number.toString()));
|
||||
case List<?> values -> new NotificationVariable.ListValue(coerceEach(name, values));
|
||||
case Map<?, ?> values -> new NotificationVariable.ObjectValue(coerceEntries(name, values));
|
||||
default ->
|
||||
throw new IllegalArgumentException(
|
||||
"variable '" + name + "' has a type the platform does not carry");
|
||||
};
|
||||
}
|
||||
|
||||
private static List<NotificationVariable> coerceEach(String name, List<?> values) {
|
||||
return values.stream().map(value -> coerce(name, value)).toList();
|
||||
}
|
||||
|
||||
private static Map<String, NotificationVariable> coerceEntries(String name, Map<?, ?> values) {
|
||||
Map<String, NotificationVariable> coerced = new LinkedHashMap<>(values.size());
|
||||
values.forEach(
|
||||
(key, value) -> {
|
||||
if (!(key instanceof String text)) {
|
||||
throw new IllegalArgumentException("variable '" + name + "' has a non-string key");
|
||||
}
|
||||
coerced.put(text, coerce(name + "." + text, value));
|
||||
});
|
||||
return Map.copyOf(coerced);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Transport shape of a template publication.
|
||||
*
|
||||
* <p>The content digest is absent by design: the platform computes it from the slots. A caller that
|
||||
* supplies both content and its digest supplies two things that can disagree, and the digest is
|
||||
* what the platform uses to decide that a published version was never edited.
|
||||
*
|
||||
* @param templateId the stable identity a submission pins
|
||||
* @param version the version pinned alongside it; versions are immutable once published
|
||||
* @param channel the channel this version renders for
|
||||
* @param locale the BCP 47 tag this version is written in
|
||||
* @param fallbackLocale the tag to resolve to when a request asks for one this version lacks
|
||||
* @param slots the sources by slot name, e.g. {@code SUBJECT}, {@code TEXT_BODY}
|
||||
* @param requiredVariables variables a submission must supply
|
||||
* @param sensitiveVariables variables that must be masked in logs, previews and failures
|
||||
*/
|
||||
public record PublishTemplateHttpRequest(
|
||||
@NotBlank String templateId,
|
||||
@Positive Long version,
|
||||
@NotBlank String channel,
|
||||
@NotBlank String locale,
|
||||
String fallbackLocale,
|
||||
@NotEmpty Map<String, String> slots,
|
||||
List<String> requiredVariables,
|
||||
List<String> sensitiveVariables) {}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion;
|
||||
|
||||
/**
|
||||
* Transport shape of a published template version.
|
||||
*
|
||||
* <p>The content is not echoed back. A caller that just sent it does not need it returned, and a
|
||||
* template body can carry the shape of a message a recipient will see.
|
||||
*
|
||||
* @param templateId the published identity
|
||||
* @param version the published version
|
||||
* @param channel the channel it renders for
|
||||
* @param locale the locale it is written in
|
||||
* @param contentDigest the digest the platform computed over its slots
|
||||
* @param status its lifecycle state
|
||||
*/
|
||||
public record PublishTemplateHttpResponse(
|
||||
String templateId,
|
||||
long version,
|
||||
String channel,
|
||||
String locale,
|
||||
String contentDigest,
|
||||
String status) {
|
||||
|
||||
/**
|
||||
* Maps a published version onto the transport shape.
|
||||
*
|
||||
* @param version the published version
|
||||
* @return the response body
|
||||
*/
|
||||
public static PublishTemplateHttpResponse from(NotificationTemplateVersion version) {
|
||||
return new PublishTemplateHttpResponse(
|
||||
version.templateId(),
|
||||
version.version(),
|
||||
version.channel().name(),
|
||||
version.locale().toLanguageTag(),
|
||||
version.contentDigest(),
|
||||
version.status().name());
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Transport shape of a notification submission.
|
||||
*
|
||||
* <p>Deliberately not the application's {@code NotificationPlan}. A plan references contact points
|
||||
* by stored identity and carries deduplication, collapse and metadata policy; a caller with an
|
||||
* address and a template has none of that, and exposing the plan over HTTP would make the transport
|
||||
* contract move whenever the platform's internal one does.
|
||||
*
|
||||
* <p>{@code address} is a contact point value. It is validated for presence only — an address
|
||||
* echoed back in a validation message is an address in a log line, which is what the platform's
|
||||
* encryption-at-rest design exists to prevent.
|
||||
*
|
||||
* @param recipientRef the caller's stable reference for the recipient; never the address
|
||||
* @param channel the channel name, matching the platform's channel vocabulary
|
||||
* @param address the contact point value in its channel's textual form
|
||||
* @param template the template id to pin
|
||||
* @param templateVersion the version to pin; 1 when the caller omits it
|
||||
* @param locale the BCP 47 language tag to render in; the platform default when omitted
|
||||
* @param variables template variables, as JSON scalars, lists and objects
|
||||
* @param idempotencyKey the caller's key; derived from the request when omitted
|
||||
* @param category the routing and reporting category; {@code transactional} when omitted
|
||||
*/
|
||||
public record SubmitNotificationHttpRequest(
|
||||
@NotBlank @Size(max = 200) String recipientRef,
|
||||
@NotBlank String channel,
|
||||
@NotBlank String address,
|
||||
@NotBlank String template,
|
||||
@Positive Long templateVersion,
|
||||
String locale,
|
||||
Map<String, Object> variables,
|
||||
@Size(max = 200) String idempotencyKey,
|
||||
@Size(max = 120) String category) {}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Transport shape of a receipt.
|
||||
*
|
||||
* <p>{@code willDeliver} is here because {@code status} alone is ambiguous to a caller: a request
|
||||
* that deduplication converged onto an earlier one is accepted and will produce no new delivery,
|
||||
* and a client that retries on "no delivery" would loop forever against a correct answer.
|
||||
*
|
||||
* @param requestId the accepted notification's id
|
||||
* @param status its request status
|
||||
* @param acceptedAt when the platform committed it
|
||||
* @param willDeliver whether this acceptance will produce a delivery of its own
|
||||
*/
|
||||
public record SubmitNotificationHttpResponse(
|
||||
String requestId, String status, Instant acceptedAt, boolean willDeliver) {
|
||||
|
||||
/**
|
||||
* Maps a receipt onto the transport shape.
|
||||
*
|
||||
* @param receipt the application receipt
|
||||
* @return the response body
|
||||
*/
|
||||
public static SubmitNotificationHttpResponse from(NotificationReceipt receipt) {
|
||||
return new SubmitNotificationHttpResponse(
|
||||
receipt.notificationId().value().toString(),
|
||||
receipt.status().name(),
|
||||
receipt.acceptedAt(),
|
||||
receipt.willDeliver());
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -34,8 +34,13 @@ class RequiresPermissionAuthorizationManagerTest {
|
||||
"user", List.of("worklog:read", "worklog:write"),
|
||||
"admin", List.of("worklog:read", "worklog:write", "worklog:close")))));
|
||||
|
||||
// A supplier, matching the production wiring: the advisor resolves the port on first use rather
|
||||
// than at construction, because an advisor is built while BeanPostProcessors are still
|
||||
// registering
|
||||
// and resolving it there instantiated the whole role/permission chain too early to be
|
||||
// post-processed. Passing the instance directly here would test a shape production does not use.
|
||||
private final RequiresPermissionAuthorizationManager manager =
|
||||
new RequiresPermissionAuthorizationManager(port);
|
||||
new RequiresPermissionAuthorizationManager(() -> port);
|
||||
|
||||
// --- fixtures: a guarded and an unguarded method ----------------------------------
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
|
||||
|
||||
The Redis wrapper and typed API described in
|
||||
`docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md` lives inside this leaf. Its
|
||||
design models the SDK as twelve Gradle modules; this repository's 19-leaf fail-closed registry
|
||||
design models the SDK as twelve Gradle modules; this repository's fail-closed registry
|
||||
outranks that layout, so each designed module is a package instead. Delivery status and the full
|
||||
adaptation rationale are in
|
||||
`docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
|
||||
|
||||
@@ -14,7 +14,7 @@ Package root: `dev.caskeleton.adapter.outbound.httpclient`.
|
||||
|
||||
The implementation follows
|
||||
`httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`.
|
||||
The design assumes 19 separate Gradle modules; this repository's fail-closed 19-leaf registry
|
||||
The design assumes 19 separate Gradle modules; this repository's fail-closed registry
|
||||
outranks that layout, so those modules are **packages** here. The mapping, and every other
|
||||
deliberate substitution, is recorded in `docs/httpclient/repository-adaptation.md`. Read it before
|
||||
moving a type between packages.
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.MessagingConfig;
|
||||
import dev.caskeleton.adapter.outbound.messaging.MessagingSettings;
|
||||
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/**
|
||||
* The one place that decides whether this application publishes to a broker.
|
||||
*
|
||||
* <p>Before this, whether {@code app.messaging.broker} was blank was the de-facto switch. That is a
|
||||
* selector doing a switch's job, and it reads badly in both directions: a blank broker with the
|
||||
* relay enabled took down startup, while a deployment that wanted no messaging at all still
|
||||
* assembled settings, a Kafka adapter configuration and two publishers. The broker id now selects
|
||||
* <em>which</em> transport, and this switch decides <em>whether</em> there is one.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "app.messaging", name = "enabled", havingValue = "true")
|
||||
@EnableConfigurationProperties(MessagingSettings.class)
|
||||
@Import({MessagingConfig.class, KafkaAdapterConfig.class})
|
||||
public class MessagingBridgeRootAutoConfiguration {}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.autoconfigure;
|
||||
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
|
||||
import org.springframework.context.EnvironmentAware;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Keeps Boot's broker auto-configurations out of the candidate set while messaging is off.
|
||||
*
|
||||
* <p>The Kafka and AMQP starters contribute theirs through Boot's import metadata, so a client
|
||||
* library on the classpath is enough to build a connection factory, a template and a listener
|
||||
* container — none of which any project condition was consulted about. It also means both brokers
|
||||
* would assemble at once simply because both libraries are present, which is a different bug the
|
||||
* same filter prevents.
|
||||
*/
|
||||
public final class MessagingOffAutoConfigurationImportFilter
|
||||
implements AutoConfigurationImportFilter, EnvironmentAware {
|
||||
|
||||
private static final String ENABLE_PROPERTY = "app.messaging.enabled";
|
||||
|
||||
private static final Set<String> BROKER_AUTO_CONFIGURATIONS =
|
||||
Set.of(
|
||||
"org.springframework.boot.kafka.autoconfigure.KafkaAutoConfiguration",
|
||||
"org.springframework.boot.kafka.autoconfigure.metrics.KafkaMetricsAutoConfiguration",
|
||||
"org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration",
|
||||
"org.springframework.boot.amqp.autoconfigure.RabbitAnnotationDrivenAutoConfiguration",
|
||||
"org.springframework.boot.amqp.autoconfigure.health.RabbitHealthContributorAutoConfiguration");
|
||||
|
||||
private Environment environment;
|
||||
|
||||
@Override
|
||||
public boolean[] match(String[] candidates, AutoConfigurationMetadata metadata) {
|
||||
boolean enabled =
|
||||
environment != null
|
||||
&& "true".equalsIgnoreCase(environment.getProperty(ENABLE_PROPERTY, "false"));
|
||||
boolean[] matches = new boolean[candidates.length];
|
||||
for (int index = 0; index < candidates.length; index++) {
|
||||
matches[index] =
|
||||
enabled
|
||||
|| candidates[index] == null
|
||||
|| !BROKER_AUTO_CONFIGURATIONS.contains(candidates[index]);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnvironment(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
|
||||
dev.caskeleton.adapter.outbound.messaging.autoconfigure.MessagingOffAutoConfigurationImportFilter
|
||||
+1
@@ -0,0 +1 @@
|
||||
dev.caskeleton.adapter.outbound.messaging.autoconfigure.MessagingBridgeRootAutoConfiguration
|
||||
+16
-3
@@ -16,7 +16,6 @@ import dev.caskeleton.application.notification.platform.security.SecretMaterialP
|
||||
import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator;
|
||||
import java.time.Duration;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -83,9 +82,23 @@ public class NotificationPlatformAutoConfiguration {
|
||||
};
|
||||
}
|
||||
|
||||
/** Contact point protection, only once key material is available. */
|
||||
/**
|
||||
* Contact point protection.
|
||||
*
|
||||
* <p>{@code @ConditionalOnBean(SecretMaterialProvider.class)} used to guard this, to express
|
||||
* "only once key material is available". It does not express that. This is a plain
|
||||
* {@code @Configuration} imported by a root, so the condition is evaluated while configurations
|
||||
* are still being parsed and answers according to what happens to be registered at that moment —
|
||||
* and the secret provider is declared by a sibling configuration the same root imports. The
|
||||
* answer was "absent", so the bean vanished and the application failed on an unsatisfied
|
||||
* dependency several layers away, in the deployment that first assembled the platform for real.
|
||||
*
|
||||
* <p>Nothing is lost by dropping it. Both configurations are reached only through the same root
|
||||
* under the same master switch, so the provider exists whenever this does — and a deployment
|
||||
* missing key material already fails at the provider itself, at startup, naming the purpose whose
|
||||
* key is missing. That is a better failure than a bean quietly not being created.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnBean(SecretMaterialProvider.class)
|
||||
@ConditionalOnMissingBean
|
||||
public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) {
|
||||
return new AesGcmContactPointProtector(secrets);
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.JavaMailSenderSmtpDispatch;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import jakarta.mail.Session;
|
||||
import java.time.Clock;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
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;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
|
||||
/**
|
||||
* The SMTP provider family, assembled (NTF-INT-001).
|
||||
*
|
||||
* <p>{@code NotificationPlatformProviderConfig} injects {@code List<ProviderRuntimeAssembler>} and
|
||||
* nothing in the composition contributed one, so the list was always empty. Assembly then produced
|
||||
* a registry with no runtimes and {@code SERVING} accepted requests that could never find a route —
|
||||
* which from outside is a platform silently dropping notifications. {@code
|
||||
* SmtpProviderRuntimeAssembler} existed in main source throughout; the only thing that ever
|
||||
* constructed it was its own unit test.
|
||||
*
|
||||
* <p>It lives in this leaf rather than in the composition root because the root must not carry
|
||||
* {@code jakarta.mail} or {@code JavaMailSender} on its own compile classpath: the mail starter is
|
||||
* an implementation detail of the family this package assembles, and a composition root that
|
||||
* imports it to wire one provider has taken ownership of that provider's transport.
|
||||
*
|
||||
* <p>Gated on {@code spring.mail.host}, not on the notification switch alone. A deployment that
|
||||
* turns the platform on without configuring a relay contributes no SMTP assembler, and assembly
|
||||
* then refuses any profile that declares {@code type: SMTP} by naming the missing family — a better
|
||||
* failure than a mail sender pointed at a default nobody chose.
|
||||
*
|
||||
* <p>The relay's address is Spring's, not a second copy. Host, port, credentials and transport
|
||||
* security come from {@code spring.mail.*} through the auto-configured {@link JavaMailSender}. One
|
||||
* relay described in two places is a defect this repository has already paid for: {@code
|
||||
* app.jpa-platform.datasource.*} validated a pool that {@code spring.datasource.hikari.*} had
|
||||
* built. {@link NotificationSmtpSettings} carries only what {@code spring.mail} has no word for —
|
||||
* the envelope sender, the per-attempt timeouts and the dispatch concurrency.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
// The master switch is not repeated here. This configuration is reachable only through
|
||||
// NotificationPlatformRuntimeConfig, which the root imports under that switch, so "off" stays a
|
||||
// structural fact rather than a condition each new configuration has to remember. What this one
|
||||
// owns
|
||||
// is the narrower question: is a relay configured at all.
|
||||
@ConditionalOnProperty(name = "spring.mail.host")
|
||||
@EnableConfigurationProperties(NotificationSmtpSettings.class)
|
||||
public class NotificationSmtpProviderConfig {
|
||||
|
||||
/** The send itself, over the mail sender Spring configured. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public SmtpDispatch notificationSmtpDispatch(JavaMailSender sender) {
|
||||
return new JavaMailSenderSmtpDispatch(sender);
|
||||
}
|
||||
|
||||
/**
|
||||
* The MIME factory, on the sender's own session.
|
||||
*
|
||||
* <p>The session is taken from {@link JavaMailSenderImpl} rather than created here, so the
|
||||
* message is built with the same properties it will be sent with. A second session would let the
|
||||
* factory and the transport disagree about encoding or TLS, and the disagreement would only show
|
||||
* as a malformed message at a recipient.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public SmtpMimeMessageFactory notificationSmtpMimeMessageFactory(JavaMailSender sender) {
|
||||
if (sender instanceof JavaMailSenderImpl impl) {
|
||||
return new SmtpMimeMessageFactory(impl.getSession());
|
||||
}
|
||||
return new SmtpMimeMessageFactory(Session.getInstance(new java.util.Properties()));
|
||||
}
|
||||
|
||||
/** SMTP reply code to retry decision. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public SmtpFailureClassifier notificationSmtpFailureClassifier() {
|
||||
return new SmtpFailureClassifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment resolution, refused by default.
|
||||
*
|
||||
* <p>Attachments come from the file server or object storage capability, and a deployment that
|
||||
* enabled neither has no way to read one. The default refuses rather than returning null: a null
|
||||
* resolution reaches the integrity guard, which then compares a digest against nothing, and the
|
||||
* message goes out without the attachment it claimed to carry.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public AttachmentResolver notificationAttachmentResolver() {
|
||||
return (reference, context) -> {
|
||||
throw new IllegalStateException(
|
||||
"an attachment was requested but no attachment capability is bound; enable the file "
|
||||
+ "server or object storage capability, or submit a notification without attachments");
|
||||
};
|
||||
}
|
||||
|
||||
/** Digest check between what a template referenced and what was read. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public AttachmentIntegrityGuard notificationAttachmentIntegrityGuard(
|
||||
AttachmentResolver resolver) {
|
||||
return new AttachmentIntegrityGuard(resolver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an SMTP send runs.
|
||||
*
|
||||
* <p>Bounded, and it aborts rather than queues without limit: an SMTP relay that has stopped
|
||||
* answering turns an unbounded queue into retained heap and a dispatch worker that never notices.
|
||||
* {@code CallerRunsPolicy} would instead block the dispatch loop on the slow relay, which stalls
|
||||
* every other channel too.
|
||||
*/
|
||||
@Bean(name = "notificationSmtpExecutor", destroyMethod = "shutdown")
|
||||
@ConditionalOnMissingBean(name = "notificationSmtpExecutor")
|
||||
public Executor notificationSmtpExecutor(NotificationSmtpSettings settings) {
|
||||
ThreadPoolExecutor executor =
|
||||
new ThreadPoolExecutor(
|
||||
1,
|
||||
settings.maxConcurrency(),
|
||||
60L,
|
||||
TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(settings.maxConcurrency() * 4),
|
||||
runnable -> {
|
||||
Thread thread = new Thread(runnable, "notification-smtp");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
},
|
||||
new ThreadPoolExecutor.AbortPolicy());
|
||||
executor.allowCoreThreadTimeOut(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The assembler itself.
|
||||
*
|
||||
* @param dispatch the send
|
||||
* @param mimeFactory the MIME builder
|
||||
* @param classifier the outcome classifier
|
||||
* @param protector redacts contact points on the way out
|
||||
* @param notificationSmtpExecutor where a send runs
|
||||
* @param attachmentGuard the attachment integrity check
|
||||
* @param settings the parts of the relay description Spring's own mail properties do not carry
|
||||
* @param sender the auto-configured sender, read for host and port
|
||||
* @param clock the clock the rate limiter measures against
|
||||
* @return the SMTP assembler, contributed into the assembly's list
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "smtpProviderRuntimeAssembler")
|
||||
public ProviderRuntimeAssembler smtpProviderRuntimeAssembler(
|
||||
SmtpDispatch dispatch,
|
||||
SmtpMimeMessageFactory mimeFactory,
|
||||
SmtpFailureClassifier classifier,
|
||||
ContactPointProtector protector,
|
||||
Executor notificationSmtpExecutor,
|
||||
AttachmentIntegrityGuard attachmentGuard,
|
||||
NotificationSmtpSettings settings,
|
||||
JavaMailSender sender,
|
||||
Clock clock) {
|
||||
return new SmtpProviderRuntimeAssembler(
|
||||
dispatch,
|
||||
mimeFactory,
|
||||
classifier,
|
||||
protector,
|
||||
notificationSmtpExecutor,
|
||||
attachmentGuard,
|
||||
transport(settings, sender),
|
||||
clock);
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay's address, read from Spring's mail configuration.
|
||||
*
|
||||
* <p>{@code SmtpProviderProperties.TlsMode} has two members and neither is plaintext, so the type
|
||||
* refuses an unencrypted relay by construction rather than by a validator somebody has to
|
||||
* remember to run. That is why the mode is a setting rather than something derived from {@code
|
||||
* spring.mail.properties.mail.smtp.starttls.enable}: a derived mode would silently become
|
||||
* "whatever that flag happened to say", including off.
|
||||
*/
|
||||
private static SmtpProviderProperties transport(
|
||||
NotificationSmtpSettings settings, JavaMailSender sender) {
|
||||
String host = settings.host();
|
||||
int port = settings.port();
|
||||
if (sender instanceof JavaMailSenderImpl impl) {
|
||||
host = impl.getHost() == null ? host : impl.getHost();
|
||||
port = impl.getPort() > 0 ? impl.getPort() : port;
|
||||
}
|
||||
return new SmtpProviderProperties(
|
||||
host,
|
||||
port,
|
||||
settings.tlsMode(),
|
||||
settings.senderIdentity(),
|
||||
settings.connectTimeout(),
|
||||
settings.readTimeout(),
|
||||
settings.writeTimeout(),
|
||||
settings.maxConcurrency());
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* The parts of an SMTP relay description that {@code spring.mail.*} has no word for.
|
||||
*
|
||||
* <p>Host, port, username, password and the mail properties stay Spring's. Repeating them here
|
||||
* would be one relay described twice, which is the shape of a defect this repository has already
|
||||
* paid for — {@code app.jpa-platform.datasource.*} validated a pool that {@code
|
||||
* spring.datasource.hikari.*} had built, so the validation passed while describing something that
|
||||
* was not running.
|
||||
*
|
||||
* <p>{@code host} and {@code port} are present all the same, as a fallback only: {@link
|
||||
* NotificationSmtpProviderConfig} reads them from the configured sender and falls back here when
|
||||
* the sender is not the standard implementation. They are never the primary source.
|
||||
*
|
||||
* @param host fallback relay host, used only when the mail sender cannot be read
|
||||
* @param port fallback relay port, same
|
||||
* @param tlsMode transport security; the type has no plaintext member, deliberately
|
||||
* @param senderIdentity the envelope sender every message is sent as
|
||||
* @param connectTimeout how long a connection attempt may take
|
||||
* @param readTimeout how long a reply may take
|
||||
* @param writeTimeout how long a write may take
|
||||
* @param maxConcurrency how many sends may run at once
|
||||
*/
|
||||
@ConfigurationProperties("ca-skeleton.notification.platform.smtp")
|
||||
public record NotificationSmtpSettings(
|
||||
@DefaultValue("localhost") String host,
|
||||
@DefaultValue("587") int port,
|
||||
@DefaultValue("STARTTLS_REQUIRED") SmtpProviderProperties.TlsMode tlsMode,
|
||||
@DefaultValue("no-reply@example.invalid") String senderIdentity,
|
||||
@DefaultValue("5s") Duration connectTimeout,
|
||||
@DefaultValue("10s") Duration readTimeout,
|
||||
@DefaultValue("10s") Duration writeTimeout,
|
||||
@DefaultValue("4") int maxConcurrency) {
|
||||
|
||||
public NotificationSmtpSettings {
|
||||
Objects.requireNonNull(tlsMode, "tlsMode");
|
||||
Objects.requireNonNull(senderIdentity, "senderIdentity");
|
||||
Objects.requireNonNull(connectTimeout, "connectTimeout");
|
||||
Objects.requireNonNull(readTimeout, "readTimeout");
|
||||
Objects.requireNonNull(writeTimeout, "writeTimeout");
|
||||
// The sender identity has a default that cannot deliver on purpose. example.invalid is reserved
|
||||
// by RFC 2606 and resolves nowhere, so a deployment that forgot to set one gets a bounce it can
|
||||
// trace rather than mail that appears to come from a real address it does not own.
|
||||
if (senderIdentity.isBlank() || senderIdentity.indexOf('@') <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.notification.platform.smtp.sender-identity must be an email address");
|
||||
}
|
||||
if (maxConcurrency < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.notification.platform.smtp.max-concurrency must be at least 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderAttemptLimiter;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntime;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpNotificationProviderAdapter;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* The first production assembler: one configured SMTP profile becomes one working provider
|
||||
* (NTF-INT-001).
|
||||
*
|
||||
* <p>{@code NotificationPlatformProviderConfig} assembles {@code List<ProviderRuntimeAssembler>}
|
||||
* and production main source contained no implementation of that interface. So {@code SERVING}
|
||||
* could not work in production whatever an operator configured: profiles bound, validation passed,
|
||||
* and the runtime registry was built empty — requests reached durable acceptance and then found no
|
||||
* eligible route, which reads from outside as the platform dropping notifications.
|
||||
*
|
||||
* <p>SMTP is the reference family because every piece above the wire already existed and was
|
||||
* tested: the adapter, the MIME factory, the failure classifier. What was missing was the send
|
||||
* itself ({@code SmtpDispatch} had no implementation) and this — the step that turns a profile into
|
||||
* a runtime.
|
||||
*
|
||||
* <p><b>Where the relay's address comes from.</b> Host, port, credentials and TLS come from
|
||||
* Spring's own {@code spring.mail.*} through the injected {@code JavaMailSender}, not from a second
|
||||
* description on the provider profile. One relay described twice is the defect this repository has
|
||||
* already paid for elsewhere — {@code app.jpa-platform.datasource.*} validated a pool that {@code
|
||||
* spring.datasource.hikari.*} had built. The profile owns what is per-profile: its timeout, its
|
||||
* concurrency and its rate.
|
||||
*
|
||||
* <p>Contributed as {@link AssembledProvider#dispatchOnly}, honestly: SMTP has no callback adapter,
|
||||
* no provider-event projector and no status-query capability. Claiming any of them would produce a
|
||||
* profile that fails on the first provider event rather than at startup.
|
||||
*/
|
||||
public final class SmtpProviderRuntimeAssembler implements ProviderRuntimeAssembler {
|
||||
|
||||
private final SmtpDispatch dispatch;
|
||||
private final SmtpMimeMessageFactory mimeFactory;
|
||||
private final SmtpFailureClassifier classifier;
|
||||
private final ContactPointProtector protector;
|
||||
private final Executor executor;
|
||||
private final AttachmentIntegrityGuard attachmentGuard;
|
||||
private final SmtpProviderProperties transport;
|
||||
private final Clock clock;
|
||||
|
||||
/**
|
||||
* Creates the assembler.
|
||||
*
|
||||
* @param dispatch the send, over the configured mail sender
|
||||
* @param mimeFactory builds the MIME message
|
||||
* @param classifier turns an SMTP outcome into a retry decision
|
||||
* @param protector redacts contact points on the way out
|
||||
* @param executor where a send runs
|
||||
* @param attachmentGuard the attachment integrity check
|
||||
* @param transport the relay's address and transport security, from {@code spring.mail.*}
|
||||
* @param clock the clock the rate limiter measures against
|
||||
*/
|
||||
public SmtpProviderRuntimeAssembler(
|
||||
SmtpDispatch dispatch,
|
||||
SmtpMimeMessageFactory mimeFactory,
|
||||
SmtpFailureClassifier classifier,
|
||||
ContactPointProtector protector,
|
||||
Executor executor,
|
||||
AttachmentIntegrityGuard attachmentGuard,
|
||||
SmtpProviderProperties transport,
|
||||
Clock clock) {
|
||||
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
|
||||
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
|
||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||
this.protector = Objects.requireNonNull(protector, "protector");
|
||||
this.executor = Objects.requireNonNull(executor, "executor");
|
||||
this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard");
|
||||
this.transport = Objects.requireNonNull(transport, "transport");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderType type() {
|
||||
return ProviderType.SMTP;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssembledProvider assemble(
|
||||
String profileId, NotificationPlatformSettings.Provider profile) {
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
|
||||
SmtpProviderProperties properties =
|
||||
new SmtpProviderProperties(
|
||||
transport.host(),
|
||||
transport.port(),
|
||||
transport.tlsMode(),
|
||||
transport.senderIdentity(),
|
||||
transport.connectTimeout(),
|
||||
profile.timeout(),
|
||||
profile.timeout(),
|
||||
profile.maxConcurrency());
|
||||
|
||||
SmtpNotificationProviderAdapter adapter =
|
||||
new SmtpNotificationProviderAdapter(
|
||||
dispatch, mimeFactory, classifier, protector, properties, executor, attachmentGuard);
|
||||
|
||||
ProviderRuntime runtime =
|
||||
new ProviderRuntime(
|
||||
snapshot(profileId, profile),
|
||||
adapter,
|
||||
new ProviderAttemptLimiter(profile.maxConcurrency(), profile.ratePerSecond(), clock));
|
||||
|
||||
return AssembledProvider.dispatchOnly(runtime, Channel.EMAIL);
|
||||
}
|
||||
|
||||
private static ProviderProfileSnapshot snapshot(
|
||||
String profileId, NotificationPlatformSettings.Provider profile) {
|
||||
return new ProviderProfileSnapshot(
|
||||
new ProviderProfileId(profileId),
|
||||
new ProviderId(ProviderType.SMTP.name().toLowerCase(java.util.Locale.ROOT)),
|
||||
Channel.EMAIL,
|
||||
profile.environment(),
|
||||
// No rotation has happened yet for a freshly assembled profile. The generation is what a
|
||||
// rotation increments; starting anywhere but the first would make the first rotation look
|
||||
// like it had already occurred.
|
||||
1L,
|
||||
capabilities(),
|
||||
Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* What SMTP can actually do, stated rather than assumed.
|
||||
*
|
||||
* <p>No batch, no provider-side idempotency, no status callback, no status query, no delivery
|
||||
* receipt, no native scheduling, cancel or collapse. SMTP is a protocol for handing a message to
|
||||
* a relay; everything past that is the relay's business and invisible to the sender. A capability
|
||||
* declared here that the protocol does not have is a promise the dispatch loop will act on.
|
||||
*/
|
||||
private static ProviderCapabilities capabilities() {
|
||||
return new ProviderCapabilities(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
25L * 1024 * 1024,
|
||||
// A relay that has not accepted the message within this is not going to; the platform's own
|
||||
// queue-age bound is what decides how long a request keeps being retried.
|
||||
java.time.Duration.ofHours(24));
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -96,10 +96,14 @@ public final class NotificationSchedulerWorker implements AutoCloseable {
|
||||
// The lease is left to expire rather than being released optimistically: a worker
|
||||
// that
|
||||
// failed mid-dispatch cannot prove what the provider did.
|
||||
// The cause chain by type, never by message. "reason=NotificationValidationException"
|
||||
// alone identified nothing — a dozen checks raise it — and a library's exception text
|
||||
// can carry a recipient address, so the types are named and the messages are not.
|
||||
log.warn(
|
||||
"notification dispatch failed worker={} reason={}",
|
||||
"notification dispatch failed worker={} reason={} causes={}",
|
||||
workerId,
|
||||
failure.getClass().getSimpleName());
|
||||
failure.getClass().getSimpleName(),
|
||||
causeChain(failure));
|
||||
} finally {
|
||||
globalConcurrency.release();
|
||||
}
|
||||
@@ -108,6 +112,25 @@ public final class NotificationSchedulerWorker implements AutoCloseable {
|
||||
return claimed.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception's cause chain, as type names only.
|
||||
*
|
||||
* @param failure the dispatch failure
|
||||
* @return the chain, outermost first, bounded so a self-referential cause cannot loop
|
||||
*/
|
||||
private static String causeChain(Throwable failure) {
|
||||
StringBuilder chain = new StringBuilder();
|
||||
Throwable current = failure.getCause();
|
||||
for (int depth = 0; current != null && depth < 8; depth++) {
|
||||
if (chain.length() > 0) {
|
||||
chain.append('<');
|
||||
}
|
||||
chain.append(current.getClass().getSimpleName());
|
||||
current = current.getCause();
|
||||
}
|
||||
return chain.length() == 0 ? "none" : chain.toString();
|
||||
}
|
||||
|
||||
/** Start the polling loop on a dedicated thread. */
|
||||
public void start() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.mail.MailException;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
|
||||
/**
|
||||
* The production SMTP send, over Spring's {@link JavaMailSender} (NTF-INT-001).
|
||||
*
|
||||
* <p>{@link SmtpDispatch} was an interface with no implementation. The adapter above it, its MIME
|
||||
* factory and its failure classifier were all complete and unit-tested against fakes, so the SMTP
|
||||
* family looked finished from every angle except the one that matters: nothing could send. That is
|
||||
* the shape of the whole NTF-INT-001 finding — configuration bound, validation passed, and the
|
||||
* runtime registry constructed empty.
|
||||
*
|
||||
* <p>Failures are translated rather than propagated. {@link SmtpDispatchException} is what {@code
|
||||
* SmtpFailureClassifier} reads to decide retryable from permanent, and a raw {@link MailException}
|
||||
* reaching the dispatch loop would be classified as an unknown failure — which this platform treats
|
||||
* as ambiguous, and ambiguous means the attempt is not retried, because a message that may already
|
||||
* have been delivered must not be sent twice.
|
||||
*/
|
||||
public final class JavaMailSenderSmtpDispatch implements SmtpDispatch {
|
||||
|
||||
private final JavaMailSender sender;
|
||||
|
||||
/**
|
||||
* Creates the dispatch.
|
||||
*
|
||||
* @param sender the configured mail sender, whose host, port and credentials come from Spring's
|
||||
* own {@code spring.mail.*} — the namespace an operator already knows, rather than a second
|
||||
* description of one relay
|
||||
*/
|
||||
public JavaMailSenderSmtpDispatch(JavaMailSender sender) {
|
||||
this.sender = Objects.requireNonNull(sender, "sender");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(MimeMessage message) {
|
||||
Objects.requireNonNull(message, "message");
|
||||
try {
|
||||
sender.send(message);
|
||||
} catch (MailException failure) {
|
||||
// dataCommitted = true, deliberately and conservatively. Spring collapses every JavaMail
|
||||
// outcome into MailException subtypes that do not say whether the body reached the server
|
||||
// before the connection broke, and the platform reads this flag to decide whether a retry
|
||||
// could duplicate a delivered message. Claiming "not committed" without evidence is the
|
||||
// assumption that sends a notification twice; claiming "committed" costs a delivery that has
|
||||
// to be reconciled, which is the failure this platform is built to survive.
|
||||
throw new SmtpDispatchException("SMTP_TRANSPORT_FAILURE", Optional.empty(), true, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-12
@@ -45,11 +45,16 @@ public final class SmtpMimeMessageFactory {
|
||||
Objects.requireNonNull(attachments, "attachments");
|
||||
|
||||
if (!(submission.content().content() instanceof EmailContent email)) {
|
||||
throw rejection();
|
||||
// A static cause, not the value: which check fired is diagnostic, what it saw may be content.
|
||||
throw rejection(
|
||||
new IllegalStateException(
|
||||
"rendered content for an SMTP submission is "
|
||||
+ submission.content().content().getClass().getSimpleName()
|
||||
+ ", not EmailContent"));
|
||||
}
|
||||
requireHeaderSafe(recipientAddress);
|
||||
requireHeaderSafe(fromAddress);
|
||||
requireHeaderSafe(email.subject());
|
||||
requireHeaderSafe(recipientAddress, "recipient");
|
||||
requireHeaderSafe(fromAddress, "sender");
|
||||
requireHeaderSafe(email.subject(), "subject");
|
||||
|
||||
try {
|
||||
MimeMessage message = new MimeMessage(session);
|
||||
@@ -71,25 +76,38 @@ public final class SmtpMimeMessageFactory {
|
||||
attachment.displayName(), () -> attachment.content(), attachment.contentType());
|
||||
}
|
||||
for (var header : email.options().approvedHeaders().entrySet()) {
|
||||
requireHeaderSafe(header.getKey());
|
||||
requireHeaderSafe(header.getValue());
|
||||
requireHeaderSafe(header.getKey(), "approved header name");
|
||||
requireHeaderSafe(header.getValue(), "approved header value");
|
||||
message.setHeader(header.getKey(), header.getValue());
|
||||
}
|
||||
return message;
|
||||
} catch (MessagingException failure) {
|
||||
throw rejection();
|
||||
throw rejection(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireHeaderSafe(String value) {
|
||||
private static void requireHeaderSafe(String value, String field) {
|
||||
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
|
||||
throw rejection();
|
||||
// The field name, never the value: a header-injection attempt is exactly the payload that
|
||||
// must
|
||||
// not be echoed into a log.
|
||||
throw rejection(new IllegalStateException(field + " contains a header separator"));
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationValidationException rejection() {
|
||||
return new NotificationValidationException(
|
||||
/**
|
||||
* The rejection, carrying what caused it.
|
||||
*
|
||||
* <p>The descriptor is deliberately the same for every construction failure — it is what gets
|
||||
* stored on the delivery row, and a per-check code there would be a cardinality problem. The
|
||||
* cause is what tells an operator which check fired, and dropping it made an SMTP dispatch
|
||||
* failure indistinguishable from any other: a lane saw "reason=NotificationValidationException"
|
||||
* and nothing else.
|
||||
*/
|
||||
private static NotificationValidationException rejection(Throwable cause) {
|
||||
NotificationFailureDescriptor descriptor =
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD);
|
||||
return new NotificationValidationException(descriptor, cause);
|
||||
}
|
||||
}
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.security;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
|
||||
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Objects;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* AES-GCM at rest, with the key id in the envelope (NTF-INT-007).
|
||||
*
|
||||
* <h2>The envelope</h2>
|
||||
*
|
||||
* <pre>
|
||||
* byte version always 1
|
||||
* byte keyIdLength 1..255, UTF-8 bytes
|
||||
* byte[] keyId
|
||||
* byte[12] nonce
|
||||
* byte[] ciphertext + GCM tag
|
||||
* </pre>
|
||||
*
|
||||
* <p><b>The key id is the whole point of having a format at all.</b> This repository's callback
|
||||
* protection stores nonce and ciphertext and nothing else, so the day the active key changes, every
|
||||
* row written under the previous one becomes unreadable and nothing in the row can say which key it
|
||||
* needed. That is not a rotation story with a gap in it; it is the absence of one. Reading the id
|
||||
* back out and asking the secret store for that specific key is what makes rotation a change of
|
||||
* default rather than a data migration.
|
||||
*
|
||||
* <p>The version byte is here for the same reason and costs one byte: a format that cannot say
|
||||
* which format it is can only ever be changed by rewriting every row first.
|
||||
*
|
||||
* <p>Authentication is not an add-on. GCM verifies the tag on decrypt, so a modified ciphertext
|
||||
* fails rather than producing plausible-looking variables — which for a notification payload would
|
||||
* mean rendering attacker-chosen content into a message a recipient trusts.
|
||||
*/
|
||||
public final class AesGcmNotificationPayloadProtection implements NotificationPayloadProtection {
|
||||
|
||||
/** The only version this class writes, and the only one it reads. */
|
||||
static final byte VERSION = 1;
|
||||
|
||||
private static final int NONCE_BYTES = 12;
|
||||
private static final int TAG_BITS = 128;
|
||||
private static final int MAX_KEY_ID_BYTES = 255;
|
||||
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final SecureRandom random;
|
||||
|
||||
/**
|
||||
* Creates the protection.
|
||||
*
|
||||
* @param secrets the key store, which owns the active key and every retired one
|
||||
* @param random the nonce source
|
||||
*/
|
||||
public AesGcmNotificationPayloadProtection(SecretMaterialProvider secrets, SecureRandom random) {
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.random = Objects.requireNonNull(random, "random");
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] protect(byte[] plaintext) {
|
||||
Objects.requireNonNull(plaintext, "plaintext");
|
||||
SecretKeyMaterial key = secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION);
|
||||
byte[] keyId = key.keyId().getBytes(StandardCharsets.UTF_8);
|
||||
if (keyId.length == 0 || keyId.length > MAX_KEY_ID_BYTES) {
|
||||
throw new IllegalStateException(
|
||||
"a payload encryption key id must be 1..255 UTF-8 bytes to fit the envelope; this one is "
|
||||
+ keyId.length);
|
||||
}
|
||||
byte[] nonce = new byte[NONCE_BYTES];
|
||||
random.nextBytes(nonce);
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
new SecretKeySpec(key.material(), "AES"),
|
||||
new GCMParameterSpec(TAG_BITS, nonce));
|
||||
// The header is authenticated, not merely prefixed: without this, the key id and version are
|
||||
// attacker-editable, and an envelope could be redirected at a key of the attacker's choosing.
|
||||
byte[] header = header(keyId);
|
||||
cipher.updateAAD(header);
|
||||
byte[] ciphertext = cipher.doFinal(plaintext);
|
||||
|
||||
return ByteBuffer.allocate(header.length + nonce.length + ciphertext.length)
|
||||
.put(header)
|
||||
.put(nonce)
|
||||
.put(ciphertext)
|
||||
.array();
|
||||
} catch (GeneralSecurityException failure) {
|
||||
// The message is deliberately shapeless: a failure here is about keys and providers, and
|
||||
// anything derived from the plaintext would put caller content into a log line.
|
||||
throw new IllegalStateException("notification payload encryption failed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] reveal(byte[] envelope) {
|
||||
Objects.requireNonNull(envelope, "envelope");
|
||||
if (envelope.length < 2) {
|
||||
throw new NotificationPayloadUnreadableException(
|
||||
"the stored payload is not an envelope", "unknown", null);
|
||||
}
|
||||
if (envelope[0] != VERSION) {
|
||||
throw new NotificationPayloadUnreadableException(
|
||||
"unsupported payload envelope version " + envelope[0], "unknown", null);
|
||||
}
|
||||
int keyIdLength = Byte.toUnsignedInt(envelope[1]);
|
||||
int nonceStart = 2 + keyIdLength;
|
||||
if (keyIdLength == 0 || envelope.length < nonceStart + NONCE_BYTES) {
|
||||
throw new NotificationPayloadUnreadableException(
|
||||
"the stored payload envelope is truncated", "unknown", null);
|
||||
}
|
||||
String keyId = new String(envelope, 2, keyIdLength, StandardCharsets.UTF_8);
|
||||
|
||||
try {
|
||||
SecretKeyMaterial key = secrets.keyById(keyId);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(key.material(), "AES"),
|
||||
new GCMParameterSpec(TAG_BITS, envelope, nonceStart, NONCE_BYTES));
|
||||
cipher.updateAAD(envelope, 0, nonceStart);
|
||||
|
||||
int cipherStart = nonceStart + NONCE_BYTES;
|
||||
return cipher.doFinal(envelope, cipherStart, envelope.length - cipherStart);
|
||||
} catch (GeneralSecurityException | RuntimeException failure) {
|
||||
// Every reason collapses into one type on purpose. An unknown key, a wrong key and a modified
|
||||
// ciphertext are the same event to a caller — the payload cannot be read — and telling them
|
||||
// apart in the message tells an attacker which of the three they achieved.
|
||||
throw new NotificationPayloadUnreadableException(
|
||||
"the stored notification payload could not be decrypted", keyId, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] header(byte[] keyId) {
|
||||
return ByteBuffer.allocate(2 + keyId.length)
|
||||
.put(VERSION)
|
||||
.put((byte) keyId.length)
|
||||
.put(keyId)
|
||||
.array();
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -28,7 +28,7 @@ public final class JacksonNotificationVariablesCodec implements NotificationVari
|
||||
try {
|
||||
return NotificationJsonMapper.mapper().writeValueAsString(new TreeMap<>(variables));
|
||||
} catch (JacksonException failure) {
|
||||
throw rejection();
|
||||
throw rejection(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,14 @@ public final class JacksonNotificationVariablesCodec implements NotificationVari
|
||||
return NotificationJsonMapper.mapper()
|
||||
.readValue(payload, new TypeReference<TreeMap<String, Object>>() {});
|
||||
} catch (JacksonException failure) {
|
||||
throw rejection();
|
||||
throw rejection(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationValidationException rejection() {
|
||||
private static NotificationValidationException rejection(Throwable cause) {
|
||||
return new NotificationValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD),
|
||||
cause);
|
||||
}
|
||||
}
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import jakarta.mail.Session;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Properties;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* One configured profile becomes one working provider (NTF-INT-001).
|
||||
*
|
||||
* <p>Before this assembler existed, {@code NotificationPlatformProviderConfig} collected {@code
|
||||
* List<ProviderRuntimeAssembler>} and production main source implemented the interface nowhere. A
|
||||
* fully configured SMTP profile therefore produced no runtime, no route and no error: requests
|
||||
* reached durable acceptance and then found nothing eligible to send them, which reads from outside
|
||||
* as the platform silently dropping notifications.
|
||||
*/
|
||||
class SmtpProviderRuntimeAssemblerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("the assembler serves the SMTP family on the email channel")
|
||||
void theAssemblerServesSmtpOnEmail() {
|
||||
assertThat(assembler().type()).isEqualTo(ProviderType.SMTP);
|
||||
assertThat(ProviderType.SMTP.channel()).isEqualTo(Channel.EMAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a configured profile assembles into a runtime bound to its own profile id")
|
||||
void aConfiguredProfileAssemblesIntoARuntime() {
|
||||
AssembledProvider assembled = assembler().assemble("primary-email", profile());
|
||||
|
||||
assertThat(assembled.channel()).isEqualTo(Channel.EMAIL);
|
||||
assertThat(assembled.runtime().profile().profileId().value()).isEqualTo("primary-email");
|
||||
assertThat(assembled.runtime().profile().providerId().value()).isEqualTo("smtp");
|
||||
assertThat(assembled.runtime().profile().environment()).isEqualTo("local");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SMTP contributes dispatch and claims nothing it cannot do")
|
||||
void smtpClaimsNothingItCannotDo() {
|
||||
AssembledProvider assembled = assembler().assemble("primary-email", profile());
|
||||
|
||||
assertThat(assembled.callback())
|
||||
.as("SMTP has no callback adapter; claiming one fails on the first provider event instead")
|
||||
.isEmpty();
|
||||
assertThat(assembled.projector()).isEmpty();
|
||||
assertThat(assembled.reconciliation())
|
||||
.as("handing a message to a relay is the end of what the sender can observe")
|
||||
.isEmpty();
|
||||
|
||||
var capabilities = assembled.runtime().profile().capabilities();
|
||||
assertThat(capabilities.statusCallback()).isFalse();
|
||||
assertThat(capabilities.statusQuery()).isFalse();
|
||||
assertThat(capabilities.providerIdempotency())
|
||||
.as("a capability declared here is a promise the dispatch loop acts on")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the per-profile bounds come from the profile, not from the shared transport")
|
||||
void thePerProfileBoundsComeFromTheProfile() {
|
||||
AssembledProvider assembled = assembler().assemble("primary-email", profile());
|
||||
|
||||
assertThat(assembled.runtime().profile().credentialGeneration())
|
||||
.as("a freshly assembled profile has had no rotation; starting elsewhere fakes one")
|
||||
.isEqualTo(1L);
|
||||
}
|
||||
|
||||
private static SmtpProviderRuntimeAssembler assembler() {
|
||||
Session session = Session.getInstance(new Properties());
|
||||
return new SmtpProviderRuntimeAssembler(
|
||||
message -> {
|
||||
throw new UnsupportedOperationException("assembly only; nothing is sent here");
|
||||
},
|
||||
new SmtpMimeMessageFactory(session),
|
||||
new SmtpFailureClassifier(),
|
||||
protector(),
|
||||
Runnable::run,
|
||||
new AttachmentIntegrityGuard((reference, context) -> null),
|
||||
new SmtpProviderProperties(
|
||||
"localhost",
|
||||
1025,
|
||||
// The type cannot express plaintext: STARTTLS_REQUIRED and IMPLICIT_TLS are the
|
||||
// only members, which is the transport refusing an unencrypted relay by
|
||||
// construction rather than by a validator somebody has to remember to run.
|
||||
SmtpProviderProperties.TlsMode.STARTTLS_REQUIRED,
|
||||
"no-reply@example.test",
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(5),
|
||||
4),
|
||||
Clock.systemUTC());
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider profile() {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
"SMTP", true, true, "local", "smtp-local", "", "", "", Duration.ofSeconds(10), 4, 8);
|
||||
}
|
||||
|
||||
/** Not a lambda: the protector has three operations, and only one of them is exercised here. */
|
||||
private static dev.caskeleton.application.notification.platform.security.ContactPointProtector
|
||||
protector() {
|
||||
return new dev.caskeleton.application.notification.platform.security.ContactPointProtector() {
|
||||
@Override
|
||||
public dev.caskeleton.application.notification.platform.security.ProtectedContactPoint
|
||||
protect(
|
||||
dev.caskeleton.application.notification.platform.contact.ContactPointValue value) {
|
||||
throw new UnsupportedOperationException("assembly only");
|
||||
}
|
||||
|
||||
@Override
|
||||
public dev.caskeleton.application.notification.platform.contact.ContactPointValue reveal(
|
||||
dev.caskeleton.application.notification.platform.security.ProtectedContactPoint value,
|
||||
dev.caskeleton.application.notification.platform.security.AccessContext context) {
|
||||
throw new UnsupportedOperationException("assembly only");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fingerprint(
|
||||
dev.caskeleton.application.notification.platform.contact.ContactPointValue value) {
|
||||
throw new UnsupportedOperationException("assembly only");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The at-rest envelope, and the rotation it exists to make possible (NTF-INT-007).
|
||||
*
|
||||
* <p>The accept path stored template variables verbatim — caller content that can be a reset code,
|
||||
* an order total or an address. What decides whether an encryption layer is real is not that it
|
||||
* encrypts; it is whether the day the key changes is a change of default or a data migration.
|
||||
*/
|
||||
class AesGcmNotificationPayloadProtectionTest {
|
||||
|
||||
private static final byte[] PAYLOAD =
|
||||
"{\"code\":\"481516\",\"total\":\"1250.00\"}".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
@DisplayName("a payload survives the round trip unchanged")
|
||||
void aPayloadSurvivesTheRoundTrip() {
|
||||
Keys keys = new Keys("payload-2026-08");
|
||||
|
||||
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
|
||||
byte[] envelope = protection.protect(PAYLOAD);
|
||||
|
||||
assertThat(protection.reveal(envelope)).isEqualTo(PAYLOAD);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the stored bytes contain no part of the plaintext")
|
||||
void theStoredBytesRevealNothing() {
|
||||
Keys keys = new Keys("payload-2026-08");
|
||||
|
||||
byte[] envelope =
|
||||
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
|
||||
|
||||
assertThat(new String(envelope, StandardCharsets.UTF_8))
|
||||
.as("a column an operator can read is a column an incident can read")
|
||||
.doesNotContain("481516")
|
||||
.doesNotContain("1250.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two encryptions of one payload differ")
|
||||
void twoEncryptionsDiffer() {
|
||||
var protection = new AesGcmNotificationPayloadProtection(new Keys("k1"), new SecureRandom());
|
||||
|
||||
assertThat(protection.protect(PAYLOAD))
|
||||
.as("a deterministic ciphertext tells an observer which two requests carried one payload")
|
||||
.isNotEqualTo(protection.protect(PAYLOAD));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a row written under a retired key is still readable after rotation")
|
||||
void aRetiredKeyStillReads() {
|
||||
Keys keys = new Keys("payload-2026-08");
|
||||
var beforeRotation = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
|
||||
byte[] oldRow = beforeRotation.protect(PAYLOAD);
|
||||
|
||||
keys.rotateTo("payload-2026-09");
|
||||
|
||||
assertThat(new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).reveal(oldRow))
|
||||
.as(
|
||||
"this is the whole reason the envelope carries a key id. Without one, rotation is a "
|
||||
+ "one-way door: every row written under the previous key becomes unreadable and "
|
||||
+ "nothing in the row can say which key it needed")
|
||||
.isEqualTo(PAYLOAD);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a new row uses the new key, so rotation actually rotates")
|
||||
void aNewRowUsesTheNewKey() {
|
||||
Keys keys = new Keys("payload-2026-08");
|
||||
keys.rotateTo("payload-2026-09");
|
||||
|
||||
byte[] envelope =
|
||||
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
|
||||
|
||||
assertThat(keyIdOf(envelope)).isEqualTo("payload-2026-09");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a modified ciphertext is refused rather than decrypted into something plausible")
|
||||
void aModifiedCiphertextIsRefused() {
|
||||
Keys keys = new Keys("k1");
|
||||
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
|
||||
byte[] envelope = protection.protect(PAYLOAD);
|
||||
envelope[envelope.length - 1] ^= 0x01;
|
||||
|
||||
assertThatThrownBy(() -> protection.reveal(envelope))
|
||||
.as("rendering attacker-chosen variables into a message a recipient trusts is the risk")
|
||||
.isInstanceOf(NotificationPayloadUnreadableException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an edited key id is refused, because the header is authenticated")
|
||||
void anEditedKeyIdIsRefused() {
|
||||
Keys keys = new Keys("k1");
|
||||
keys.add("k2");
|
||||
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
|
||||
byte[] envelope = protection.protect(PAYLOAD);
|
||||
envelope[2] = 'k';
|
||||
envelope[3] = '2';
|
||||
|
||||
assertThatThrownBy(() -> protection.reveal(envelope))
|
||||
.as(
|
||||
"the header is passed as AAD, so redirecting an envelope at another key fails the tag "
|
||||
+ "rather than being attempted")
|
||||
.isInstanceOf(NotificationPayloadUnreadableException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown key names itself in the failure")
|
||||
void anUnknownKeyNamesItself() {
|
||||
Keys keys = new Keys("payload-2026-08");
|
||||
byte[] envelope =
|
||||
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
|
||||
Keys emptied = new Keys("payload-2026-09");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new AesGcmNotificationPayloadProtection(emptied, new SecureRandom())
|
||||
.reveal(envelope))
|
||||
.isInstanceOf(NotificationPayloadUnreadableException.class)
|
||||
.as("the operator's next question is always which key is missing")
|
||||
.hasMessageContaining("payload-2026-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a truncated or foreign envelope is refused, not misread")
|
||||
void aTruncatedEnvelopeIsRefused() {
|
||||
var protection = new AesGcmNotificationPayloadProtection(new Keys("k1"), new SecureRandom());
|
||||
|
||||
assertThatCode(() -> protection.reveal(new byte[] {1}))
|
||||
.isInstanceOf(NotificationPayloadUnreadableException.class);
|
||||
assertThatCode(
|
||||
() -> protection.reveal("plaintext row from before".getBytes(StandardCharsets.UTF_8)))
|
||||
.as("a pre-migration plaintext row must fail loudly rather than decode into nonsense")
|
||||
.isInstanceOf(NotificationPayloadUnreadableException.class);
|
||||
}
|
||||
|
||||
private static String keyIdOf(byte[] envelope) {
|
||||
return new String(envelope, 2, Byte.toUnsignedInt(envelope[1]), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** A key store with history, which is what rotation needs and what the envelope addresses. */
|
||||
private static final class Keys implements SecretMaterialProvider {
|
||||
|
||||
private final Map<String, SecretKeyMaterial> byId = new LinkedHashMap<>();
|
||||
private String activeId;
|
||||
|
||||
Keys(String activeId) {
|
||||
add(activeId);
|
||||
this.activeId = activeId;
|
||||
}
|
||||
|
||||
void add(String keyId) {
|
||||
byte[] material = new byte[32];
|
||||
Arrays.fill(material, (byte) keyId.hashCode());
|
||||
byId.put(keyId, new SecretKeyMaterial(keyId, SecretPurpose.PAYLOAD_ENCRYPTION, material));
|
||||
}
|
||||
|
||||
void rotateTo(String keyId) {
|
||||
add(keyId);
|
||||
activeId = keyId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecretKeyMaterial activeKey(SecretPurpose purpose) {
|
||||
return byId.get(activeId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecretKeyMaterial keyById(String keyId) {
|
||||
SecretKeyMaterial key = byId.get(keyId);
|
||||
if (key == null) {
|
||||
throw new IllegalStateException("no key " + keyId);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ adapters implement application/domain ports directly and must not depend on this
|
||||
|
||||
The platform in `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` is
|
||||
implemented here. The design models it as 18 Stable library modules; this repository's fail-closed
|
||||
19-leaf registry outranks that layout, so those modules are **packages** in this leaf and
|
||||
registry outranks that layout, so those modules are **packages** in this leaf and
|
||||
`docs/jpa/repository-adaptation.md` records the mapping. Read it before moving a type between
|
||||
packages.
|
||||
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.config;
|
||||
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Registers the JPA adapter's scanned components, which nothing registered (JPA-INT-006).
|
||||
*
|
||||
* <p>The composition root's {@code @ComponentScan} excludes {@code
|
||||
* dev.caskeleton.adapter.outbound.persistence.**} by regex, and that exclusion is correct: it is
|
||||
* what makes an optional capability optional, so a deployment with JPA off assembles no persistence
|
||||
* beans rather than assembling them and hoping each one remembered to carry the switch.
|
||||
*
|
||||
* <p>What was missing is the other half. Eight classes in this leaf are written as scanned
|
||||
* components — {@code SpringTransactionPort}, {@code PersistenceExceptionTranslator}, {@code
|
||||
* StandardSqlStateErrorMapping}, {@code DomainContextAuditContextPort}, the idempotency store and
|
||||
* its reaper, and the outbox store and its reaper — and once the broad scan stopped reaching them,
|
||||
* nothing else did. They are annotated {@code @Component} and {@code @Repository} and were beans in
|
||||
* no running application: {@code TransactionPort} in particular had no implementation at all, so
|
||||
* every use case that opens a transaction had no port to open it with.
|
||||
*
|
||||
* <p>It surfaced as an unsatisfied dependency the first time a capability that needs a transaction
|
||||
* was actually assembled — the notification orchestrator, in the local-notification-ingest lane —
|
||||
* rather than as anything a unit test could see, because each of these classes is constructed
|
||||
* directly by its own tests.
|
||||
*
|
||||
* <p>So the scan is restored, narrowed to the packages it should always have covered and reachable
|
||||
* only through {@code PersistenceJpaRootAutoConfiguration}, which carries the JPA master switch.
|
||||
* Off is still structural.
|
||||
*
|
||||
* <p>Two packages are deliberately absent:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code ..persistence.fileserver} — gated on its own capability switch, scanned by {@link
|
||||
* dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig};
|
||||
* <li>{@code ..persistence.notification} — assembled explicitly, bean by bean, by {@code
|
||||
* NotificationJpaPersistenceFacade}.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Components under these packages keep their own {@code @ConditionalOnProperty} guards; being
|
||||
* scanned makes them candidates, not unconditional beans.
|
||||
*
|
||||
* <p>{@code ..persistence.lock} is in the list for the same reason and with the same history.
|
||||
* {@code DistributedLockPersistenceConfig} owns both lock providers — the in-process registry and
|
||||
* the JDBC one — and was registered by nothing but a test calling {@code ctx.register(...)}. So a
|
||||
* single-instance deployment had no {@code DistributedLockPort} at all, and a multi-instance one
|
||||
* could not start: the composition root's own {@code DistributedLockConfig} asks for a bean
|
||||
* qualified {@code jdbcDistributedLock} that only that configuration declares.
|
||||
*
|
||||
* <p>Each package's {@code @ConfigurationProperties} type is enabled by a configuration inside that
|
||||
* same package — {@code JpaTransactionConfig} for {@code JpaTransactionSettings}, {@code
|
||||
* DistributedLockPersistenceConfig} for {@code LockSettings} — rather than from here. Enabling them
|
||||
* centrally would give {@code config} an edge to {@code lock} and {@code transaction} that the
|
||||
* module map does not grant it, and the map is right: this class knows which packages to scan, not
|
||||
* what is inside them. They need enabling at all because {@code @ConfigurationPropertiesScan}
|
||||
* excludes this tree as deliberately as {@code @ComponentScan} does.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ComponentScan(
|
||||
basePackages = {
|
||||
"dev.caskeleton.adapter.outbound.persistence.audit",
|
||||
"dev.caskeleton.adapter.outbound.persistence.failure",
|
||||
"dev.caskeleton.adapter.outbound.persistence.idempotency",
|
||||
"dev.caskeleton.adapter.outbound.persistence.lock",
|
||||
"dev.caskeleton.adapter.outbound.persistence.outbox",
|
||||
"dev.caskeleton.adapter.outbound.persistence.transaction"
|
||||
})
|
||||
public class JpaAdapterComponentsConfig {}
|
||||
+26
-10
@@ -11,13 +11,25 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
* simple name avoids {@code JpaConfig} to dodge a bean-name collision with the sample module. See
|
||||
* README "config".
|
||||
*
|
||||
* <p>The package list is enumerated rather than given as the persistence root, and the omission is
|
||||
* deliberate: {@code ...persistence.notification} is an <em>opt-in</em> capability whose schema
|
||||
* stream is not in the default Flyway location. Scanning the whole root put its entities into the
|
||||
* persistence unit unconditionally, so a deployment that never enabled notification still had
|
||||
* {@code ddl-auto=validate} looking for {@code notification_request} — and failed to boot over a
|
||||
* capability it had switched off. {@code NotificationJpaPersistenceConfig} owns that scan and only
|
||||
* when the capability is on.
|
||||
* <p>The package list is enumerated rather than given as the persistence root, and two omissions
|
||||
* are deliberate. {@code ...persistence.notification} and {@code ...persistence.fileserver} are
|
||||
* <em>opt-in</em> capabilities whose schema streams are not in the default Flyway location, so
|
||||
* their tables do not exist in a deployment that never asked for them. Scanning the whole root put
|
||||
* those entities into the persistence unit unconditionally, and {@code ddl-auto=validate} then
|
||||
* looked for {@code notification_request} and {@code fs_cleanup_item} in deployments that had
|
||||
* switched both capabilities off — failing the boot over capabilities they had declined. {@code
|
||||
* NotificationJpaPersistenceConfig} and {@code FileserverJpaPersistenceConfig} own those scans,
|
||||
* each behind the same switch its adapter beans already carried.
|
||||
*
|
||||
* <p>The fileserver case was the more expensive of the two: it blocked every JPA-on Compose lane,
|
||||
* and it was invisible under H2, whose {@code create-drop} builds whatever the entities describe.
|
||||
* It took a real PostgreSQL with a real migration history to see.
|
||||
*
|
||||
* <p>Neither gated configuration is imported from here, and neither is found by a component scan:
|
||||
* {@code dev.caskeleton.adapter.outbound.persistence..*} is excluded from the composition root's
|
||||
* scan by design, and {@code config} is allowed to depend on {@code api} alone. The composition
|
||||
* root registers them, which is where the decision belongs — it is the only place that knows both
|
||||
* which capabilities are on and which JPA vendor is composed.
|
||||
*
|
||||
* <p>A new always-installed sub-package must be added here; leaving it out is a silent omission
|
||||
* rather than a compile error, which is what {@code PersistenceEntityScanCoverageTest} checks.
|
||||
@@ -32,7 +44,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
"dev.caskeleton.adapter.outbound.persistence.envers",
|
||||
"dev.caskeleton.adapter.outbound.persistence.experimental",
|
||||
"dev.caskeleton.adapter.outbound.persistence.failure",
|
||||
"dev.caskeleton.adapter.outbound.persistence.fileserver",
|
||||
"dev.caskeleton.adapter.outbound.persistence.hibernate",
|
||||
"dev.caskeleton.adapter.outbound.persistence.idempotency",
|
||||
"dev.caskeleton.adapter.outbound.persistence.lock",
|
||||
@@ -54,7 +65,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
"dev.caskeleton.adapter.outbound.persistence.envers",
|
||||
"dev.caskeleton.adapter.outbound.persistence.experimental",
|
||||
"dev.caskeleton.adapter.outbound.persistence.failure",
|
||||
"dev.caskeleton.adapter.outbound.persistence.fileserver",
|
||||
"dev.caskeleton.adapter.outbound.persistence.hibernate",
|
||||
"dev.caskeleton.adapter.outbound.persistence.idempotency",
|
||||
"dev.caskeleton.adapter.outbound.persistence.lock",
|
||||
@@ -69,7 +79,13 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
})
|
||||
public class PersistenceJpaConfig {
|
||||
|
||||
/** The one sub-package deliberately excluded above, named so a test can assert the exclusion. */
|
||||
/**
|
||||
* An opt-in sub-package deliberately excluded above, named so a test can assert the exclusion.
|
||||
*/
|
||||
public static final String OPT_IN_NOTIFICATION_PACKAGE =
|
||||
"dev.caskeleton.adapter.outbound.persistence.notification";
|
||||
|
||||
/** The other one. Its tables live only in {@code db/migration/jpa/fileserver}. */
|
||||
public static final String OPT_IN_FILESERVER_PACKAGE =
|
||||
"dev.caskeleton.adapter.outbound.persistence.fileserver";
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.fileserver;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/**
|
||||
* Scans the fileserver capability's entities and repositories, and only when it is enabled.
|
||||
*
|
||||
* <p>The second capability to need this, and it was found the same way as the first: by running.
|
||||
* Six entities here map six tables — {@code fs_file}, {@code fs_upload_session}, {@code
|
||||
* fs_verification_result}, {@code fs_quota_reservation}, {@code fs_recovery_item} and {@code
|
||||
* fs_cleanup_item} — and all six live in {@code db/migration/jpa/fileserver}, a stream applied only
|
||||
* when the capability is on. The primary Flyway location is {@code db/migration/postgresql}, which
|
||||
* creates none of them.
|
||||
*
|
||||
* <p>So an unconditional scan put those entities into the persistence unit of every deployment, and
|
||||
* {@code ddl-auto=validate} against real PostgreSQL failed on {@code fs_cleanup_item} — a table the
|
||||
* deployment had correctly never created, for a capability it had switched off. Every JPA-on
|
||||
* Compose lane was blocked on it. The adapter beans in this package already carried this exact
|
||||
* condition; the entity metadata did not, so "disabled" meant two different things one annotation
|
||||
* apart.
|
||||
*
|
||||
* <p>The condition is the same master switch those beans use, so disabled means one thing
|
||||
* everywhere: no entity metadata, no repository beans, no schema expectation.
|
||||
*
|
||||
* <p>{@code @ComponentScan} is here for the reason {@link
|
||||
* dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig} exists: the nine
|
||||
* {@code @Repository} adapters in this package carry the condition quoted above but were reached by
|
||||
* no scan at all once the composition root stopped scanning the persistence tree, so the condition
|
||||
* had nothing to decide about. {@code @EnableJpaRepositories} does not cover them — it registers
|
||||
* Spring Data interfaces, and these are classes that consume those interfaces.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
@EntityScan(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
|
||||
@EnableJpaRepositories(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
|
||||
@ComponentScan(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
|
||||
public class FileserverJpaPersistenceConfig {}
|
||||
+5
@@ -3,6 +3,7 @@ package dev.caskeleton.adapter.outbound.persistence.lock;
|
||||
import dev.caskeleton.application.lock.DistributedLockPort;
|
||||
import javax.sql.DataSource;
|
||||
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;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
@@ -17,6 +18,10 @@ import org.springframework.integration.support.locks.DefaultLockRegistry;
|
||||
* "lock" for the provider-selection and wiring rationale.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
// LockSettings is bound here rather than by the composition root's @ConfigurationPropertiesScan,
|
||||
// which excludes this tree so a JPA-off deployment binds no persistence settings. Both providers
|
||||
// below are built from its lease TTL, so without this the configuration cannot assemble either.
|
||||
@EnableConfigurationProperties(LockSettings.class)
|
||||
public class DistributedLockPersistenceConfig {
|
||||
|
||||
/** In-process adapter; active when {@code multi-instance-enabled} is {@code false} or absent. */
|
||||
|
||||
+36
-22
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.notification.configuration;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig;
|
||||
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaActivation;
|
||||
import dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditJpaRepository;
|
||||
import dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentJpaRepository;
|
||||
@@ -56,6 +57,7 @@ import java.util.Locale;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
/**
|
||||
@@ -84,6 +86,14 @@ import org.springframework.jdbc.core.JdbcOperations;
|
||||
prefix = "ca-skeleton.notification.platform",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
// The entity scan travels with the stores it serves. NotificationJpaPersistenceConfig carries the
|
||||
// same condition and was imported by nothing at all — the composition root's component scan
|
||||
// excludes
|
||||
// this package by design and no configuration named it — so the capability had entity metadata
|
||||
// nowhere, whether it was switched on or off. Importing it here means a composition that reaches
|
||||
// the
|
||||
// facade cannot get the stores without the mappings they need.
|
||||
@Import(NotificationJpaPersistenceConfig.class)
|
||||
public class NotificationJpaPersistenceFacade {
|
||||
|
||||
/**
|
||||
@@ -132,11 +142,21 @@ public class NotificationJpaPersistenceFacade {
|
||||
.JdbcNotificationServingState(jdbc, clock);
|
||||
}
|
||||
|
||||
/** Entity/record mapping. */
|
||||
/**
|
||||
* Entity/record mapping, including the at-rest envelope (NTF-INT-007).
|
||||
*
|
||||
* <p>The protection is a required constructor argument rather than an optional one, so a
|
||||
* composition cannot assemble this leaf's notification stores while leaving the payload in
|
||||
* plaintext. That was the whole risk: the store existed, wiring it was one import away, and
|
||||
* nothing about the store's shape said the row it wrote held caller content unprotected.
|
||||
*/
|
||||
@Bean
|
||||
public NotificationRecordMapper notificationRecordMapper(
|
||||
NotificationRoutingPlanCodecPort routingPlans, NotificationVariablesCodecPort variables) {
|
||||
return new NotificationRecordMapper(routingPlans, variables);
|
||||
NotificationRoutingPlanCodecPort routingPlans,
|
||||
NotificationVariablesCodecPort variables,
|
||||
dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection
|
||||
payloadProtection) {
|
||||
return new NotificationRecordMapper(routingPlans, variables, payloadProtection);
|
||||
}
|
||||
|
||||
/** Request and recipient persistence. */
|
||||
@@ -163,7 +183,19 @@ public class NotificationJpaPersistenceFacade {
|
||||
return new JpaRecipientLeaseStore(recipients, clock);
|
||||
}
|
||||
|
||||
/** Attempt persistence, which also serves the resolver and projection ports. */
|
||||
/**
|
||||
* Attempt persistence, which is also the resolver and the projection store.
|
||||
*
|
||||
* <p>One bean, not four. {@link JpaDeliveryAttemptStore} implements {@link
|
||||
* DeliveryAttemptStorePort}, {@link DeliveryAttemptResolverPort} and {@link
|
||||
* DeliveryProjectionStorePort}, so injection by any of those types already finds it — and three
|
||||
* further {@code @Bean} methods returning this same instance under three more names is what made
|
||||
* the container refuse to start: asking for the concrete type matched several definitions of one
|
||||
* object, and {@code deliveryAttemptResolverPort} could not be built at all.
|
||||
*
|
||||
* <p>It failed only once this facade was actually assembled. Nothing imported it before, so the
|
||||
* duplication sat in a class the runtime never read.
|
||||
*/
|
||||
@Bean
|
||||
public JpaDeliveryAttemptStore deliveryAttemptStore(
|
||||
DeliveryAttemptJpaRepository attempts,
|
||||
@@ -173,24 +205,6 @@ public class NotificationJpaPersistenceFacade {
|
||||
return new JpaDeliveryAttemptStore(attempts, recipients, hasher, clock);
|
||||
}
|
||||
|
||||
/** Attempt store as its application port. */
|
||||
@Bean
|
||||
public DeliveryAttemptStorePort deliveryAttemptStorePort(JpaDeliveryAttemptStore store) {
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Attempt resolution for incoming provider events. */
|
||||
@Bean
|
||||
public DeliveryAttemptResolverPort deliveryAttemptResolverPort(JpaDeliveryAttemptStore store) {
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Projection persistence. */
|
||||
@Bean
|
||||
public DeliveryProjectionStorePort deliveryProjectionStorePort(JpaDeliveryAttemptStore store) {
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Append-only provider event ledger. */
|
||||
@Bean
|
||||
public ProviderEventLedger providerEventLedger(
|
||||
|
||||
+4
-1
@@ -61,7 +61,10 @@ public final class JpaNotificationRequestStore implements NotificationRequestSto
|
||||
? null
|
||||
: request.template().locale().toLanguageTag(),
|
||||
request.strategyType(),
|
||||
request.variablesPayload(),
|
||||
// Through the mapper, not raw. This native insert names every column itself and so
|
||||
// bypasses toEntity, where payload protection lives; passing the plaintext here stored
|
||||
// recipient-facing variables in clear text and produced a row nothing could read back.
|
||||
mapper.protectPayload(request.variablesPayload()),
|
||||
request.scheduleAt().orElse(null),
|
||||
request.notBefore().orElse(null),
|
||||
request.expiresAt().orElse(null),
|
||||
|
||||
+68
-3
@@ -14,7 +14,11 @@ import dev.caskeleton.application.notification.platform.dispatch.NotificationReq
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord;
|
||||
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
|
||||
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -31,11 +35,27 @@ public final class NotificationRecordMapper {
|
||||
|
||||
private final NotificationRoutingPlanCodecPort routingPlans;
|
||||
private final NotificationVariablesCodecPort variables;
|
||||
private final NotificationPayloadProtection payloadProtection;
|
||||
|
||||
/**
|
||||
* Creates the mapper.
|
||||
*
|
||||
* @param routingPlans the frozen route-plan codec
|
||||
* @param variables the variables codec
|
||||
* @param payloadProtection the at-rest envelope (NTF-INT-007). Applied here, at the storage
|
||||
* boundary, rather than in the accept path: "at rest" means in the row, and the application
|
||||
* necessarily holds the plaintext in memory because it has to render it. What this removes is
|
||||
* the plaintext sitting in {@code notification_request.variables_payload} for as long as the
|
||||
* request is retained — caller content that can be a reset code, an order total or an
|
||||
* address.
|
||||
*/
|
||||
public NotificationRecordMapper(
|
||||
NotificationRoutingPlanCodecPort routingPlans, NotificationVariablesCodecPort variables) {
|
||||
NotificationRoutingPlanCodecPort routingPlans,
|
||||
NotificationVariablesCodecPort variables,
|
||||
NotificationPayloadProtection payloadProtection) {
|
||||
this.routingPlans = Objects.requireNonNull(routingPlans, "routingPlans");
|
||||
this.variables = Objects.requireNonNull(variables, "variables");
|
||||
this.payloadProtection = Objects.requireNonNull(payloadProtection, "payloadProtection");
|
||||
}
|
||||
|
||||
/** Map a stored request. */
|
||||
@@ -53,7 +73,7 @@ public final class NotificationRecordMapper {
|
||||
? Locale.ROOT
|
||||
: Locale.forLanguageTag(entity.templateLocale())),
|
||||
entity.strategyType(),
|
||||
entity.variablesPayload(),
|
||||
revealPayload(entity.variablesPayload()),
|
||||
Optional.ofNullable(entity.scheduleAt()),
|
||||
Optional.ofNullable(entity.notBefore()),
|
||||
Optional.ofNullable(entity.expiresAt()),
|
||||
@@ -98,6 +118,51 @@ public final class NotificationRecordMapper {
|
||||
entity.updatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the payload for storage: an AES-GCM envelope, base64 for the text column.
|
||||
*
|
||||
* <p>Base64 rather than a column type change, deliberately. Moving {@code variables_payload} to
|
||||
* {@code bytea} would touch the entity, the mapper, the native upsert and every stored row, and
|
||||
* would buy a third of the bytes; keeping the column and changing what is in it makes the
|
||||
* migration a rewrite of values rather than of a schema.
|
||||
*
|
||||
* <p>Public because {@link #toEntity} is not the only write path. {@code
|
||||
* JpaNotificationRequestStore.insert} claims the idempotency key with a native insert that names
|
||||
* every column itself, so it never reaches the mapper — and it wrote the plaintext straight into
|
||||
* the column. Every accepted request was stored unencrypted, and the first read-back refused it
|
||||
* as "not an envelope", which is the reveal side working exactly as intended and the only reason
|
||||
* this was visible at all. A store that writes rows this mapper is expected to read must protect
|
||||
* them the way this mapper protects them.
|
||||
*/
|
||||
public String protectPayload(String plaintext) {
|
||||
if (plaintext == null) {
|
||||
return null;
|
||||
}
|
||||
return Base64.getEncoder()
|
||||
.encodeToString(payloadProtection.protect(plaintext.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a stored payload.
|
||||
*
|
||||
* <p>A row that cannot be decrypted throws rather than returning null or empty. A caller handed
|
||||
* an empty payload renders every variable as nothing and sends "Hello , your code is " to a real
|
||||
* person — the failure delivered instead of reported.
|
||||
*/
|
||||
private String revealPayload(String stored) {
|
||||
if (stored == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] envelope;
|
||||
try {
|
||||
envelope = Base64.getDecoder().decode(stored);
|
||||
} catch (IllegalArgumentException malformedEnvelope) {
|
||||
throw new NotificationPayloadUnreadableException(
|
||||
"the stored notification payload is not a base64 envelope", "unknown", malformedEnvelope);
|
||||
}
|
||||
return new String(payloadProtection.reveal(envelope), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Build a new request entity. */
|
||||
public NotificationRequestEntity toEntity(NotificationRequestRecord record) {
|
||||
return new NotificationRequestEntity(
|
||||
@@ -110,7 +175,7 @@ public final class NotificationRecordMapper {
|
||||
record.template().version(),
|
||||
record.template().locale().toLanguageTag(),
|
||||
record.strategyType(),
|
||||
record.variablesPayload(),
|
||||
protectPayload(record.variablesPayload()),
|
||||
record.scheduleAt().orElse(null),
|
||||
record.notBefore().orElse(null),
|
||||
record.expiresAt().orElse(null),
|
||||
|
||||
+35
-5
@@ -15,20 +15,26 @@ import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
/**
|
||||
* PostgreSQL vendor persistence configuration: imports the core JPA config and registers the vendor
|
||||
* {@code @Bean}s. See the module README.
|
||||
* PostgreSQL vendor persistence configuration: registers the vendor {@code @Bean}s. See the module
|
||||
* README.
|
||||
*
|
||||
* <p>The vendor configuration is the entry point into the capability, and it imports the core JPA
|
||||
* config rather than the reverse. Inverting that to give the composition root a single import
|
||||
* produced a package cycle — {@code config} would import {@code postgresql}, which needs {@code
|
||||
* PersistenceVendorSettings} back from {@code config} — so the composition root names both vendor
|
||||
* configurations instead, and the persistence export surface says so explicitly.
|
||||
*
|
||||
* <p>{@code matchIfMissing = true} keeps PostgreSQL the default: this configuration was
|
||||
* unconditional before {@link PersistenceVendorSettings} existed, and a deployment that never sets
|
||||
* the selector must keep the vendor it already runs.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@Import(PersistenceJpaConfig.class)
|
||||
@ConditionalOnProperty(
|
||||
prefix = PersistenceVendorSettings.PREFIX,
|
||||
name = "vendor",
|
||||
havingValue = "postgresql",
|
||||
matchIfMissing = true)
|
||||
@Import(PersistenceJpaConfig.class)
|
||||
public class PostgreSqlPersistenceConfig {
|
||||
|
||||
@Bean
|
||||
@@ -52,8 +58,32 @@ public class PostgreSqlPersistenceConfig {
|
||||
return new PostgreSqlIdempotencyClaimRepository(entityManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* The vendor's migration location, as a default rather than as an override.
|
||||
*
|
||||
* <p>This unconditionally called {@code locations(...)}, which replaces whatever Spring bound
|
||||
* from {@code spring.flyway.locations}. An operator could therefore set {@code
|
||||
* SPRING_FLYWAY_LOCATIONS} to add the capability streams, watch Flyway report a successful
|
||||
* migration, and get only the vendor stream — the property was read, bound, and then discarded by
|
||||
* a customizer that runs after it. The {@code local-notification-ingest} lane set seven locations
|
||||
* and applied one.
|
||||
*
|
||||
* <p>It is the same shape as {@code application-local.yml}'s literal pins, which outranked every
|
||||
* environment a caller supplied, and the same fix: contribute the value when nobody has chosen
|
||||
* one, and stay out of the way when somebody has. A deployment that names its own locations is
|
||||
* responsible for including this one, which is exactly the responsibility it took by naming them.
|
||||
*
|
||||
* @param environment the resolved environment, consulted for an operator-supplied value
|
||||
* @return the customizer
|
||||
*/
|
||||
@Bean
|
||||
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() {
|
||||
return configuration -> configuration.locations("classpath:db/migration/postgresql");
|
||||
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer(
|
||||
org.springframework.core.env.Environment environment) {
|
||||
return configuration -> {
|
||||
String chosen = environment.getProperty("spring.flyway.locations", "").trim();
|
||||
if (chosen.isEmpty()) {
|
||||
configuration.locations("classpath:db/migration/postgresql");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Binds {@link JpaTransactionSettings}, which nothing bound.
|
||||
*
|
||||
* <p>{@link SpringTransactionPort} takes it as a constructor argument, and the composition root's
|
||||
* {@code @ConfigurationPropertiesScan} deliberately excludes this tree so a deployment with JPA off
|
||||
* binds no persistence settings. That exclusion left the type unbound in deployments with JPA
|
||||
* <em>on</em> as well, so the transaction port could not be constructed at all — it surfaced as an
|
||||
* unsatisfied dependency the first time a capability that opens a transaction was assembled.
|
||||
*
|
||||
* <p>It lives in this package, next to the settings and the component that needs them, because the
|
||||
* module map grants {@code config} an edge to {@code api} only; a central enablement would need
|
||||
* edges into every package whose settings it named.
|
||||
*
|
||||
* <p>Reached through {@code JpaAdapterComponentsConfig}'s scan, so it is registered exactly when
|
||||
* the JPA master switch is on.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(JpaTransactionSettings.class)
|
||||
public class JpaTransactionConfig {}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
-- five-adapter-runtime-remediation Wave 2 — the capability stream's half of the same correction.
|
||||
--
|
||||
-- V1 here creates idempotency_record with `CREATE TABLE IF NOT EXISTS` and char(64), the same
|
||||
-- declaration db/migration/postgresql/V1 uses. The two streams keep separate history tables and
|
||||
-- their relative order is not fixed, so whichever creates the table has to be corrected by whichever
|
||||
-- runs next. Both carry the same guarded conversion; the reasoning is recorded once, in
|
||||
-- db/migration/postgresql/V10__idempotency_request_hash_varchar.sql.
|
||||
--
|
||||
-- Editing V1 in place would have been smaller and wrong: an applied migration's checksum is a
|
||||
-- promise to every deployment that already ran it.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON c.oid = a.attrelid
|
||||
WHERE c.relname = 'idempotency_record'
|
||||
AND a.attname = 'request_hash'
|
||||
AND NOT a.attisdropped
|
||||
AND format_type(a.atttypid, a.atttypmod) = 'character(64)'
|
||||
) THEN
|
||||
ALTER TABLE idempotency_record
|
||||
ALTER COLUMN request_hash TYPE varchar(64);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
-- five-adapter-runtime-remediation Wave 2 / NTF-INT-007 — the at-rest envelope's row guard.
|
||||
--
|
||||
-- notification_request.variables_payload now holds a base64 AES-GCM envelope instead of the accepted
|
||||
-- request's template variables in plaintext. The variables are the message's own content — a reset
|
||||
-- code, an order total, a delivery address — so the column held caller-supplied sensitive data for as
|
||||
-- long as the request was retained.
|
||||
--
|
||||
-- This is a guard rather than a backfill, and the reason is a fact worth stating: no deployment of
|
||||
-- this repository can have written such a row. NotificationJpaPersistenceFacade, which assembles
|
||||
-- JpaNotificationRequestStore, was imported by nothing, so the write path existed in code and was
|
||||
-- reachable from no composition. A backfill here would be re-encrypting rows that cannot exist, and
|
||||
-- it would need the key material, which a migration has no business holding.
|
||||
--
|
||||
-- A fork that wired the store itself is the case this exists for. It fails the migration rather than
|
||||
-- letting the application meet the rows at runtime, where the mapper throws
|
||||
-- NotificationPayloadUnreadableException per request and the failure looks like a decryption bug
|
||||
-- instead of an un-migrated table.
|
||||
--
|
||||
-- Silently reinterpreting a plaintext row is the option this deliberately does not take. Accepting
|
||||
-- both shapes would mean the protection can be bypassed by writing plaintext, which is a control that
|
||||
-- announces itself and then declines to hold.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
unprotected bigint;
|
||||
BEGIN
|
||||
IF to_regclass('public.notification_request') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- A base64 envelope contains only the base64 alphabet; a stored JSON payload contains at least
|
||||
-- one of { " : , which none of them is. That is enough to separate the two without decoding.
|
||||
SELECT count(*)
|
||||
INTO unprotected
|
||||
FROM notification_request
|
||||
WHERE variables_payload IS NOT NULL
|
||||
AND variables_payload !~ '^[A-Za-z0-9+/]+=*$';
|
||||
|
||||
IF unprotected > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'notification_request holds % row(s) whose variables_payload is not an at-rest envelope. '
|
||||
'These predate NTF-INT-007 and contain caller content in plaintext. Re-encrypt them with '
|
||||
'the active PAYLOAD_ENCRYPTION key before applying this migration; this step refuses to '
|
||||
'reinterpret them, because accepting both shapes would let the protection be bypassed by '
|
||||
'writing plaintext.', unprotected;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
-- five-adapter-runtime-remediation Wave 2 — align request_hash with the mapping that validates it.
|
||||
--
|
||||
-- V1 declared request_hash as char(64) while IdempotencyRecordEntity maps it as length = 64, which
|
||||
-- Hibernate reads as varchar(64). Startup with ddl-auto=validate against real PostgreSQL therefore
|
||||
-- failed:
|
||||
--
|
||||
-- Schema-validation: wrong column type encountered in column [request_hash] in table
|
||||
-- [idempotency_record]; found [bpchar (Types#CHAR)], but expecting [varchar(64) (Types#VARCHAR)]
|
||||
--
|
||||
-- It was invisible for as long as local development ran on H2, whose create-drop builds the schema
|
||||
-- from the entities and so can never disagree with them. Every other string column in this table is
|
||||
-- varchar; char(n) was the outlier, and PostgreSQL gives it no storage or speed advantage while
|
||||
-- blank-padding every value.
|
||||
--
|
||||
-- varchar is the direction rather than changing the entity, because a 64-character hex digest never
|
||||
-- uses the padding and the rest of the codebase — the entity, the H2 composition, the JPQL — already
|
||||
-- assumes varchar. The bpchar-to-varchar cast strips trailing blanks, so a padded value converts
|
||||
-- losslessly.
|
||||
--
|
||||
-- Guarded because this stream and db/migration/jpa/idempotency both create the table and their
|
||||
-- relative order is not fixed: whichever ran first, this converts only a column that is still
|
||||
-- character(64), and a table rewrite is not paid twice.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON c.oid = a.attrelid
|
||||
WHERE c.relname = 'idempotency_record'
|
||||
AND a.attname = 'request_hash'
|
||||
AND NOT a.attisdropped
|
||||
AND format_type(a.atttypid, a.atttypmod) = 'character(64)'
|
||||
) THEN
|
||||
ALTER TABLE idempotency_record
|
||||
ALTER COLUMN request_hash TYPE varchar(64);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.readiness;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
||||
|
||||
/**
|
||||
* Hibernate's verdict on the always-installed mapping, against the always-applied migrations.
|
||||
*
|
||||
* <p>The notification and fileserver capabilities each have this check; the default persistence
|
||||
* unit — the one every JPA deployment builds whether it enables a capability or not — did not. So
|
||||
* the only thing that ever ran {@code validate} over it against real PostgreSQL was a Compose lane,
|
||||
* four minutes at a time, and what it found was {@code request_hash} declared {@code char(64)} by
|
||||
* the migration and mapped {@code varchar(64)} by the entity. Every JPA-on deployment failed to
|
||||
* start on it.
|
||||
*
|
||||
* <p>Nothing caught it earlier because local development runs on H2, whose {@code create-drop}
|
||||
* builds the schema from the entities and therefore cannot disagree with them. A vendor that
|
||||
* generates the schema can never report a mismatch with it.
|
||||
*
|
||||
* <p>The scanned packages are read from {@link PersistenceJpaConfig} rather than listed here, so a
|
||||
* package added to the shipped scan is covered by this test the moment it is added — which is the
|
||||
* only arrangement that keeps the check honest as the unit grows.
|
||||
*/
|
||||
@Tag("jpa-migration")
|
||||
class PostgreSqlDefaultPersistenceUnitIntegrationTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Hibernate validates the always-installed mapping against the migrated schema")
|
||||
void hibernateValidatesTheDefaultUnitAgainstTheMigratedSchema() throws Exception {
|
||||
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
|
||||
migrate(database);
|
||||
|
||||
assertThatCode(() -> entityManagerFactory(database.dataSource()).close())
|
||||
.as(
|
||||
"this is the check every JPA-on boot performs; a column type the mapping disagrees "
|
||||
+ "with fails it, and until this test existed only a Compose lane would say so")
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"request_hash is varchar, which is what the entity maps and what the rest of the "
|
||||
+ "table uses")
|
||||
void theIdempotencyHashColumnIsVarchar() throws Exception {
|
||||
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
|
||||
migrate(database);
|
||||
|
||||
String type =
|
||||
new JdbcTemplate(database.dataSource())
|
||||
.queryForObject(
|
||||
"select format_type(a.atttypid, a.atttypmod)"
|
||||
+ " from pg_attribute a"
|
||||
+ " join pg_class c on c.oid = a.attrelid"
|
||||
+ " where c.relname = 'idempotency_record'"
|
||||
+ " and a.attname = 'request_hash'"
|
||||
+ " and not a.attisdropped",
|
||||
String.class);
|
||||
|
||||
assertThat(type)
|
||||
.as("char(n) blank-pads every value and was the only such column in this table")
|
||||
.isEqualTo("character varying(64)");
|
||||
}
|
||||
}
|
||||
|
||||
private static EntityManagerFactory entityManagerFactory(DataSource dataSource) {
|
||||
LocalContainerEntityManagerFactoryBean factoryBean =
|
||||
new LocalContainerEntityManagerFactoryBean();
|
||||
factoryBean.setDataSource(dataSource);
|
||||
factoryBean.setPackagesToScan(
|
||||
PersistenceJpaConfig.class.getAnnotation(EntityScan.class).basePackages());
|
||||
factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
|
||||
factoryBean.setJpaPropertyMap(
|
||||
Map.of(
|
||||
"hibernate.hbm2ddl.auto", "validate",
|
||||
"hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect"));
|
||||
factoryBean.afterPropertiesSet();
|
||||
return factoryBean.getObject();
|
||||
}
|
||||
|
||||
private static void migrate(PostgreSqlReadinessSupport database) {
|
||||
Flyway.configure()
|
||||
.dataSource(database.dataSource())
|
||||
.locations("classpath:db/migration/postgresql")
|
||||
.load()
|
||||
.migrate();
|
||||
}
|
||||
}
|
||||
+67
-29
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig;
|
||||
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
@@ -20,24 +21,45 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
* What goes into the persistence unit, and what is allowed to stay out of it.
|
||||
*
|
||||
* <p>The scan named the persistence root, so every sub-package was in the unit whether the
|
||||
* deployment wanted it or not. Notification is opt-in — its schema stream is not in the default
|
||||
* Flyway location — so a deployment with the feature switched off still had {@code
|
||||
* ddl-auto=validate} demanding {@code notification_request}, and failed to boot over a capability
|
||||
* it had declined.
|
||||
* deployment wanted it or not. Notification and fileserver are opt-in — their schema streams are
|
||||
* not in the default Flyway location — so a deployment with the feature switched off still had
|
||||
* {@code ddl-auto=validate} demanding {@code notification_request} or {@code fs_cleanup_item}, and
|
||||
* failed to boot over a capability it had declined.
|
||||
*
|
||||
* <p>Both were found by running, not reading, and the second only after the first was fixed:
|
||||
* notification failed a Testcontainers boot, fileserver blocked every JPA-on Compose lane. Neither
|
||||
* is visible under H2, whose {@code create-drop} builds whatever the entities describe.
|
||||
*
|
||||
* <p>An enumerated list fixes that and introduces a different risk: a new always-installed
|
||||
* sub-package is a silent omission rather than a compile error. This test is the other half.
|
||||
* sub-package is a silent omission rather than a compile error. This test is the other half — and
|
||||
* it is parameterized over the opt-in capabilities so a third one cannot be added with half the
|
||||
* treatment.
|
||||
*/
|
||||
class PersistenceEntityScanCoverageTest {
|
||||
|
||||
/** Each opt-in capability: its package, its own configuration, and the switch that gates it. */
|
||||
private record OptInCapability(String packageName, Class<?> configuration, String switchPrefix) {}
|
||||
|
||||
private static final List<OptInCapability> OPT_IN =
|
||||
List.of(
|
||||
new OptInCapability(
|
||||
PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE,
|
||||
NotificationJpaPersistenceConfig.class,
|
||||
"ca-skeleton.notification.platform"),
|
||||
new OptInCapability(
|
||||
PersistenceJpaConfig.OPT_IN_FILESERVER_PACKAGE,
|
||||
FileserverJpaPersistenceConfig.class,
|
||||
"app.fileserver-platform"));
|
||||
|
||||
/** Sub-packages that hold no JPA entity or repository and therefore need no scan. */
|
||||
private static final Set<String> NOT_SCANNED =
|
||||
Set.of(
|
||||
// Vendor-selection configuration only.
|
||||
"config",
|
||||
"h2",
|
||||
// The opt-in capability, scanned by NotificationJpaPersistenceConfig instead.
|
||||
"notification");
|
||||
// The opt-in capabilities, each scanned by its own gated configuration instead.
|
||||
"notification",
|
||||
"fileserver");
|
||||
|
||||
@Test
|
||||
@DisplayName("every persistence sub-package is either scanned or explicitly exempt")
|
||||
@@ -61,40 +83,56 @@ class PersistenceEntityScanCoverageTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the opt-in notification package is not in the always-installed scan")
|
||||
void notificationIsNotScannedUnconditionally() {
|
||||
@DisplayName("no opt-in package is in the always-installed scan")
|
||||
void noOptInPackageIsScannedUnconditionally() {
|
||||
List<String> entityPackages =
|
||||
List.of(PersistenceJpaConfig.class.getAnnotation(EntityScan.class).basePackages());
|
||||
List<String> repositoryPackages =
|
||||
List.of(
|
||||
PersistenceJpaConfig.class.getAnnotation(EnableJpaRepositories.class).basePackages());
|
||||
|
||||
for (OptInCapability capability : OPT_IN) {
|
||||
assertThat(entityPackages)
|
||||
.as(
|
||||
"scanning %s here puts its tables into ddl-auto=validate for every deployment",
|
||||
capability.packageName())
|
||||
.doesNotContain(capability.packageName());
|
||||
assertThat(repositoryPackages).doesNotContain(capability.packageName());
|
||||
}
|
||||
assertThat(entityPackages)
|
||||
.as("scanning it here puts its tables into ddl-auto=validate for every deployment")
|
||||
.doesNotContain(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
|
||||
assertThat(repositoryPackages).doesNotContain(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
|
||||
assertThat(entityPackages)
|
||||
.as("and naming the root would scan it by inclusion")
|
||||
.as("and naming the root would scan every one of them by inclusion")
|
||||
.doesNotContain("dev.caskeleton.adapter.outbound.persistence");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the notification scan is behind the same switch as its beans")
|
||||
void notificationIsScannedOnlyWhenEnabled() {
|
||||
var condition =
|
||||
NotificationJpaPersistenceConfig.class.getAnnotation(
|
||||
org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class);
|
||||
@DisplayName("each opt-in scan is behind the same switch as its own beans")
|
||||
void everyOptInScanIsGatedByItsCapabilitySwitch() {
|
||||
for (OptInCapability capability : OPT_IN) {
|
||||
var condition =
|
||||
capability
|
||||
.configuration()
|
||||
.getAnnotation(
|
||||
org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class);
|
||||
|
||||
assertThat(condition).isNotNull();
|
||||
assertThat(condition.prefix()).isEqualTo("ca-skeleton.notification.platform");
|
||||
assertThat(condition.name()).containsExactly("enabled");
|
||||
assertThat(condition.havingValue()).isEqualTo("true");
|
||||
assertThat(
|
||||
List.of(
|
||||
NotificationJpaPersistenceConfig.class
|
||||
.getAnnotation(EntityScan.class)
|
||||
.basePackages()))
|
||||
.containsExactly(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
|
||||
assertThat(condition)
|
||||
.as(
|
||||
"%s must carry the capability condition, or the scan is unconditional again",
|
||||
capability.configuration().getSimpleName())
|
||||
.isNotNull();
|
||||
assertThat(condition.prefix()).isEqualTo(capability.switchPrefix());
|
||||
assertThat(condition.name()).containsExactly("enabled");
|
||||
assertThat(condition.havingValue()).isEqualTo("true");
|
||||
assertThat(List.of(capability.configuration().getAnnotation(EntityScan.class).basePackages()))
|
||||
.containsExactly(capability.packageName());
|
||||
assertThat(
|
||||
List.of(
|
||||
capability
|
||||
.configuration()
|
||||
.getAnnotation(EnableJpaRepositories.class)
|
||||
.basePackages()))
|
||||
.as("entities without repositories is half a scan, and fails at the first query")
|
||||
.containsExactly(capability.packageName());
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> scannedLeafNames(Class<?> configuration) {
|
||||
|
||||
@@ -292,12 +292,17 @@ Closure<String> renderMongoApiSurface = {
|
||||
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
|
||||
}
|
||||
|
||||
// The approval flag is read at configuration time and carried in, not fetched from `project`
|
||||
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
|
||||
// incompatible with the configuration cache — which this build will need before it can adopt one.
|
||||
boolean mongoApiSurfaceUpdateApproved = project.hasProperty('approveMongoApiSurfaceChange')
|
||||
|
||||
tasks.register('verifyMongoApiSurface') {
|
||||
group = 'verification'
|
||||
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
|
||||
|
||||
doLast {
|
||||
if (project.hasProperty('approveMongoApiSurfaceChange')) {
|
||||
if (mongoApiSurfaceUpdateApproved) {
|
||||
throw new GradleException(
|
||||
'verifyMongoApiSurface is read-only; use updateMongoApiSurface to record an ' +
|
||||
'approved change.')
|
||||
|
||||
+1
-1
@@ -22,6 +22,6 @@ import org.springframework.context.annotation.Configuration;
|
||||
prefix = "ca-skeleton.persistence-mongo",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@EnableConfigurationProperties(MongoPersistenceProperties.class)
|
||||
@EnableConfigurationProperties(MongoPersistenceSettings.class)
|
||||
@ImportAutoConfiguration({MongoAutoConfiguration.class, DataMongoAutoConfiguration.class})
|
||||
public class MongoPersistenceConfig {}
|
||||
|
||||
+11
-4
@@ -7,12 +7,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* ca-skeleton.persistence-mongo.*}.
|
||||
*
|
||||
* <p>The Mongo <b>connection URI</b> is intentionally NOT modelled here — it is read from Spring's
|
||||
* own standard {@code spring.data.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}),
|
||||
* which keeps credentials, host, and database wiring in the one place operators already expect.
|
||||
* This class owns only the module's opt-in switch.
|
||||
* own standard {@code spring.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}), which
|
||||
* keeps credentials, host, and database wiring in the one place operators already expect. This
|
||||
* class owns only the module's opt-in switch.
|
||||
*
|
||||
* <p>{@code spring.mongodb.*} is the canonical namespace in Spring Boot 4; {@code
|
||||
* spring.data.mongodb.*} is deprecated at error level in its metadata. This Javadoc named the
|
||||
* deprecated one, which is the worst place for that drift to sit: an operator reads the class that
|
||||
* owns the switch, sets the property it points at, and gets a deprecation they did not choose. The
|
||||
* Compose lanes have always supplied {@code SPRING_MONGODB_URI}, so only the documentation was
|
||||
* behind. {@code MongoNamespaceContractTest} keeps it from drifting back.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.persistence-mongo")
|
||||
public class MongoPersistenceProperties {
|
||||
public class MongoPersistenceSettings {
|
||||
|
||||
/**
|
||||
* Whether to activate the MongoDB scaffolding. Defaults to {@code false} so the driver never
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration;
|
||||
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/**
|
||||
* The one place that decides whether this application talks to MongoDB.
|
||||
*
|
||||
* <p>Three things behaved like the master before this: the auto-configuration import filter, a
|
||||
* component-scanned persistence configuration, and the platform auto-configuration — each reading
|
||||
* the same property, and each able to assemble something the others believed was off. The filter
|
||||
* keeps its job of holding Boot's own Mongo auto-configurations out of the candidate set, because
|
||||
* that has to happen before any project condition is evaluated; what it no longer is, is an
|
||||
* authority.
|
||||
*
|
||||
* <p>It sits in the leaf root, not in {@code autoconfigure}, because it names the opt-in
|
||||
* configuration and the enablement settings that live here — and the root package already names
|
||||
* {@code autoconfigure}. Declaring the edge both ways would make the module's package graph cyclic,
|
||||
* which its boundary test refuses; the leaf root is where this file belongs by that graph's own
|
||||
* description, "the opt-in filter and the Spring configuration entry points".
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.persistence-mongo",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@EnableConfigurationProperties(MongoPersistenceSettings.class)
|
||||
@Import({
|
||||
MongoPersistenceConfig.class,
|
||||
MongoPlatformAutoConfiguration.class,
|
||||
MongoDriverObservabilityAutoConfiguration.class
|
||||
})
|
||||
public class MongoRootAutoConfiguration {}
|
||||
+2
-2
@@ -24,7 +24,7 @@ import org.springframework.context.annotation.Configuration;
|
||||
prefix = "ca-skeleton.persistence-mongo",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@EnableConfigurationProperties(MongoAdvancedProperties.class)
|
||||
@EnableConfigurationProperties(MongoAdvancedSettings.class)
|
||||
public class MongoAdvancedConfiguration {
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ public class MongoAdvancedConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public MongoAdvancedCapabilityGuard mongoAdvancedCapabilityGuard(
|
||||
MongoAdvancedProperties properties) {
|
||||
MongoAdvancedSettings properties) {
|
||||
return new MongoAdvancedCapabilityGuard(properties.toFlags());
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -22,19 +22,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* startup instead of being ignored — which matters more than usual here, because the failure mode
|
||||
* of a misspelt Advanced flag is a capability that stays off while its operator believes it is on.
|
||||
*/
|
||||
@ConfigurationProperties(MongoAdvancedProperties.PREFIX)
|
||||
public record MongoAdvancedProperties(Map<MongoCapability, CapabilitySwitch> advanced) {
|
||||
@ConfigurationProperties(MongoAdvancedSettings.PREFIX)
|
||||
public record MongoAdvancedSettings(Map<MongoCapability, CapabilitySwitch> advanced) {
|
||||
|
||||
/** The module prefix; the {@code advanced} component completes the documented property path. */
|
||||
public static final String PREFIX = "ca-skeleton.persistence-mongo";
|
||||
|
||||
public MongoAdvancedProperties {
|
||||
public MongoAdvancedSettings {
|
||||
advanced = advanced == null || advanced.isEmpty() ? Map.of() : Map.copyOf(advanced);
|
||||
}
|
||||
|
||||
/** Nothing enabled — the default for a deployment that configures no Advanced capability. */
|
||||
public static MongoAdvancedProperties none() {
|
||||
return new MongoAdvancedProperties(Map.of());
|
||||
public static MongoAdvancedSettings none() {
|
||||
return new MongoAdvancedSettings(Map.of());
|
||||
}
|
||||
|
||||
/** The flag set this configuration describes. */
|
||||
+3
-4
@@ -2,7 +2,6 @@ package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.mongo.observation.MongoDriverObservabilityConfiguration;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -25,7 +24,7 @@ import org.springframework.context.annotation.Bean;
|
||||
* different things on purpose, and neither is derived from the other, so a command appears once in
|
||||
* each rather than twice in one.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.persistence-mongo",
|
||||
name = "enabled",
|
||||
@@ -38,9 +37,9 @@ public class MongoDriverObservabilityAutoConfiguration {
|
||||
@ConditionalOnMissingBean(name = "mongoDriverObservabilityCustomizer")
|
||||
public MongoClientSettingsBuilderCustomizer mongoDriverObservabilityCustomizer(
|
||||
MeterRegistry registry,
|
||||
org.springframework.beans.factory.ObjectProvider<MongoPlatformProperties> properties) {
|
||||
org.springframework.beans.factory.ObjectProvider<MongoPlatformSettings> properties) {
|
||||
String profile =
|
||||
properties.getIfAvailable(MongoPlatformProperties::empty).profiles().keySet().stream()
|
||||
properties.getIfAvailable(MongoPlatformSettings::empty).profiles().keySet().stream()
|
||||
.findFirst()
|
||||
.orElse("default");
|
||||
MongoDriverObservabilityConfiguration observability =
|
||||
|
||||
+47
-9
@@ -17,7 +17,6 @@ import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.time.Clock;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
@@ -39,13 +38,13 @@ import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
* transitively. The admin gateway is absent for the same reason, and deliberately: it is
|
||||
* constructed by a migration or deployment job with its own credential.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnClass(MongoTemplate.class)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.persistence-mongo",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@EnableConfigurationProperties(MongoPlatformProperties.class)
|
||||
@EnableConfigurationProperties(MongoPlatformSettings.class)
|
||||
@Import(MongoMappingConfiguration.class)
|
||||
public class MongoPlatformAutoConfiguration {
|
||||
|
||||
@@ -148,7 +147,7 @@ public class MongoPlatformAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnBean(MongoTopologyProbe.class)
|
||||
public InitializingBean mongoPlatformStartupCheck(
|
||||
MongoPlatformProperties properties,
|
||||
MongoPlatformSettings properties,
|
||||
MongoTopologyProbe probe,
|
||||
ObjectProvider<dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile>
|
||||
runtimeSecurity,
|
||||
@@ -174,15 +173,54 @@ public class MongoPlatformAutoConfiguration {
|
||||
probe,
|
||||
security,
|
||||
admin,
|
||||
// Capability flags come from the same beans a deployment supplies for them; the
|
||||
// properties record carries only profiles.
|
||||
true,
|
||||
true,
|
||||
// From settings, not literals. These were `true, true`, which told the validator that
|
||||
// transactions and change streams were both wanted whatever the deployment had
|
||||
// configured — and then validated the topology against that invented answer.
|
||||
properties.transactions(),
|
||||
properties.changeStreams(),
|
||||
versions)
|
||||
.validate();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a platform that is on with no way to look at the server it talks to (MNG-INT-003).
|
||||
*
|
||||
* <p>{@code mongoPlatformStartupCheck} above is conditioned on a {@link MongoTopologyProbe},
|
||||
* which is right — the check is about the live server, and only a composition root knows how to
|
||||
* reach it. But a condition is also an exit: a deployment that enables the platform and supplies
|
||||
* no probe got no validation at all, silently, and not supplying a bean is exactly what an
|
||||
* operator who has not finished wiring will do.
|
||||
*
|
||||
* <p>So the absence is a failure of its own. Deliberately <b>not</b> conditioned on the probe: a
|
||||
* requirement that only applies when the thing it requires is present is not a requirement.
|
||||
*
|
||||
* <p>Scoped to a platform that is actually configured, which is what {@code profiles} being
|
||||
* non-empty means — the settings record already treats "module opted in, no platform profile yet"
|
||||
* as a state that must start. This repository ships no probe: it is built from the live
|
||||
* data-plane client by the composition root that owns the connection, which is a fork's decision.
|
||||
* Requiring one from every deployment that merely switches the module on would refuse the
|
||||
* module's own opt-in contract, and the {@code local-mongo} lane with it.
|
||||
*/
|
||||
@Bean
|
||||
public InitializingBean mongoTopologyProbeRequirement(
|
||||
MongoPlatformSettings properties, ObjectProvider<MongoTopologyProbe> probe) {
|
||||
return () -> {
|
||||
if (properties.profiles().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (probe.getIfAvailable() == null) {
|
||||
throw new IllegalStateException(
|
||||
"the Mongo platform has configured profiles but no MongoTopologyProbe bean, so the "
|
||||
+ "startup validator has nothing to ask about the server: supply a probe built from "
|
||||
+ "the live data-plane client, or remove the platform profiles. Starting without one "
|
||||
+ "means the topology, the Stable API level and the credential's real capabilities "
|
||||
+ "are checked by nothing — silently, because the check was conditioned on the very "
|
||||
+ "bean whose absence it should report.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential generation registry.
|
||||
*
|
||||
@@ -211,7 +249,7 @@ public class MongoPlatformAutoConfiguration {
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnBean(MongoTopologyProbe.class)
|
||||
public MongoPlatformHealthIndicator mongoPlatformHealthIndicator(
|
||||
MongoTopologyProbe probe, MongoPlatformProperties properties) {
|
||||
MongoTopologyProbe probe, MongoPlatformSettings properties) {
|
||||
return new MongoPlatformHealthIndicator(probe, properties, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ public final class MongoPlatformHealthIndicator {
|
||||
|
||||
private final MongoTopologyProbe probe;
|
||||
|
||||
private final MongoPlatformProperties properties;
|
||||
private final MongoPlatformSettings properties;
|
||||
|
||||
private final int availableSecondaries;
|
||||
|
||||
@@ -30,7 +30,7 @@ public final class MongoPlatformHealthIndicator {
|
||||
public static final int DEFAULT_REQUIRED_SECONDARIES = 2;
|
||||
|
||||
public MongoPlatformHealthIndicator(
|
||||
MongoTopologyProbe probe, MongoPlatformProperties properties, int availableSecondaries) {
|
||||
MongoTopologyProbe probe, MongoPlatformSettings properties, int availableSecondaries) {
|
||||
this(probe, properties, availableSecondaries, DEFAULT_REQUIRED_SECONDARIES);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public final class MongoPlatformHealthIndicator {
|
||||
*/
|
||||
public MongoPlatformHealthIndicator(
|
||||
MongoTopologyProbe probe,
|
||||
MongoPlatformProperties properties,
|
||||
MongoPlatformSettings properties,
|
||||
int availableSecondaries,
|
||||
int requiredSecondaries) {
|
||||
this.probe = Objects.requireNonNull(probe, "probe");
|
||||
|
||||
+19
-4
@@ -16,20 +16,35 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* <p>Every profile is validated at binding time. A misconfigured profile that is only reached at
|
||||
* runtime fails during the first request that touches it, which is both later and harder to
|
||||
* attribute than a refused startup.
|
||||
*
|
||||
* <p>{@code transactions} is a subordinate switch, off until a deployment asks for it; when on, the
|
||||
* startup validator verifies the data-plane credential's real replica-set capability rather than
|
||||
* assuming it. Both it and {@code changeStreams} used to be literal {@code true}s passed into
|
||||
* {@link MongoStartupValidator}, which told the validator that two capabilities were wanted
|
||||
* whatever the deployment had configured.
|
||||
*/
|
||||
@ConfigurationProperties("ca-skeleton.persistence-mongo.platform")
|
||||
public record MongoPlatformProperties(Map<String, MongoProfileProperties> profiles) {
|
||||
public record MongoPlatformSettings(
|
||||
Map<String, MongoProfileProperties> profiles, boolean transactions, boolean changeStreams) {
|
||||
|
||||
public MongoPlatformProperties {
|
||||
public MongoPlatformSettings {
|
||||
// Absent rather than empty is the normal case: a deployment that has opted the module in but
|
||||
// configured no platform profile yet must still start, so binding treats "no profiles" as an
|
||||
// empty map instead of a binding failure.
|
||||
profiles = profiles == null ? Map.of() : Map.copyOf(profiles);
|
||||
// Experimental, and therefore not a switch (MNG-INT-003). The driver-side source — watch,
|
||||
// resumeAfter/startAfter, cursor lifetime, reconnection — is not shipped; what exists is policy
|
||||
// and value objects that do not add up to a running consumer. Accepting the flag and ignoring
|
||||
// it
|
||||
// would leave an operator believing it took effect, so the value is refused rather than stored:
|
||||
// zero beans, zero threads, and a `true` that cannot be honoured never becomes one that looks
|
||||
// honoured.
|
||||
changeStreams = false;
|
||||
}
|
||||
|
||||
/** An empty configuration, for a deployment that has not opted the platform in. */
|
||||
public static MongoPlatformProperties empty() {
|
||||
return new MongoPlatformProperties(Map.of());
|
||||
public static MongoPlatformSettings empty() {
|
||||
return new MongoPlatformSettings(Map.of(), false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
+2
-2
@@ -21,7 +21,7 @@ import java.util.Optional;
|
||||
*/
|
||||
public final class MongoStartupValidator {
|
||||
|
||||
private final MongoPlatformProperties properties;
|
||||
private final MongoPlatformSettings properties;
|
||||
|
||||
private final MongoTopologyProbe probe;
|
||||
|
||||
@@ -36,7 +36,7 @@ public final class MongoStartupValidator {
|
||||
private final MongoSchemaVersionRange schemaVersionRange;
|
||||
|
||||
public MongoStartupValidator(
|
||||
MongoPlatformProperties properties,
|
||||
MongoPlatformSettings properties,
|
||||
MongoTopologyProbe probe,
|
||||
MongoSecurityProfile runtimeSecurity,
|
||||
MongoCredentialReference adminCredential,
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.client;
|
||||
|
||||
import com.mongodb.ConnectionString;
|
||||
import com.mongodb.MongoClientSettings;
|
||||
import com.mongodb.ServerApi;
|
||||
import com.mongodb.ServerApiVersion;
|
||||
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings;
|
||||
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties;
|
||||
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference;
|
||||
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialResolver;
|
||||
import dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bson.UuidRepresentation;
|
||||
|
||||
/**
|
||||
* The one place the typed profile becomes the settings the driver is built from (MNG-INT-002).
|
||||
*
|
||||
* <p>The profile, the credential resolver, the TLS and Stable-API flags and the pool and timeout
|
||||
* policy all existed and were all unit-tested. None of them reached a {@link MongoClientSettings}:
|
||||
* the values were checked as intermediate objects and whatever the driver ended up configured with
|
||||
* was decided elsewhere, by defaults nobody had chosen. A policy that nothing applies reads exactly
|
||||
* like a policy that is applied — the tests pass, the record is populated, and the client connects
|
||||
* with a three-second timeout it inherited from the driver rather than the two the profile states.
|
||||
*
|
||||
* <p>Two cardinality rules this enforces by construction:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>One credential resolution, for the active profile only.</b> A profile present in the map
|
||||
* but not selected never has its secret read — resolving it reaches a secret store for a
|
||||
* credential nobody asked for, and the audit trail then records an access that happened for
|
||||
* no reason anybody can reconstruct.
|
||||
* <li><b>The connection string does not escape.</b> The resolver hands it to a function; what
|
||||
* comes back is a settings object carrying only what the driver needs. The URI is a local for
|
||||
* the duration of one call and never a field, a bean property or a {@code toString}.
|
||||
* </ul>
|
||||
*/
|
||||
public final class MongoClientSettingsFactory {
|
||||
|
||||
private final MongoPlatformSettings settings;
|
||||
private final String activeProfile;
|
||||
private final MongoCredentialResolver credentials;
|
||||
|
||||
/**
|
||||
* Creates the factory.
|
||||
*
|
||||
* @param settings the platform settings holding every configured profile
|
||||
* @param activeProfile the profile this deployment selected
|
||||
* @param credentials the resolver that turns a {@code secret://} reference into a URI
|
||||
*/
|
||||
public MongoClientSettingsFactory(
|
||||
MongoPlatformSettings settings, String activeProfile, MongoCredentialResolver credentials) {
|
||||
this.settings = Objects.requireNonNull(settings, "settings");
|
||||
this.activeProfile = Objects.requireNonNull(activeProfile, "activeProfile");
|
||||
this.credentials = Objects.requireNonNull(credentials, "credentials");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the settings for the active profile.
|
||||
*
|
||||
* @return the driver settings
|
||||
* @throws dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException when
|
||||
* the selected profile is not configured
|
||||
*/
|
||||
public MongoClientSettings create() {
|
||||
MongoProfileProperties profile = settings.require(activeProfile);
|
||||
MongoCredentialReference reference =
|
||||
new MongoCredentialReference(profile.uriSecret(), MongoPrincipalRole.APP_WRITE);
|
||||
|
||||
return credentials.withConnectionString(reference, uri -> build(profile, uri));
|
||||
}
|
||||
|
||||
private static MongoClientSettings build(MongoProfileProperties profile, String uri) {
|
||||
MongoClientSettings.Builder builder =
|
||||
MongoClientSettings.builder()
|
||||
.applyConnectionString(new ConnectionString(uri))
|
||||
// Pinned from the manifest rather than left to the driver, whose own default has
|
||||
// changed
|
||||
// across major versions — a value that moves under a stored document is a migration
|
||||
// nobody wrote.
|
||||
.uuidRepresentation(uuidRepresentationOf(profile))
|
||||
.applyToSocketSettings(
|
||||
socket ->
|
||||
socket
|
||||
.connectTimeout(profile.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(profile.socketReadTimeout().toMillis(), TimeUnit.MILLISECONDS))
|
||||
.applyToClusterSettings(
|
||||
cluster ->
|
||||
cluster.serverSelectionTimeout(
|
||||
profile.serverSelectionTimeout().toMillis(), TimeUnit.MILLISECONDS))
|
||||
.applyToConnectionPoolSettings(
|
||||
pool ->
|
||||
pool.minSize(profile.poolMinSize())
|
||||
.maxSize(profile.poolMaxSize())
|
||||
.maxWaitTime(profile.poolMaxWaitTime().toMillis(), TimeUnit.MILLISECONDS));
|
||||
|
||||
if (profile.tlsRequired()) {
|
||||
// Stated by the profile, applied here. A profile that declares TLS and connects without it is
|
||||
// the failure this leaf's production validation exists to prevent, and it cannot prevent it
|
||||
// from a record the driver never reads.
|
||||
builder.applyToSslSettings(ssl -> ssl.enabled(true));
|
||||
}
|
||||
if (profile.stableApiStrict()) {
|
||||
builder.serverApi(
|
||||
ServerApi.builder()
|
||||
.version(ServerApiVersion.V1)
|
||||
.strict(true)
|
||||
.deprecationErrors(true)
|
||||
.build());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static UuidRepresentation uuidRepresentationOf(MongoProfileProperties profile) {
|
||||
return switch (profile.uuidRepresentation()) {
|
||||
case STANDARD -> UuidRepresentation.STANDARD;
|
||||
// Readable for migration and never written, which is the manifest's rule rather than the
|
||||
// driver's: the driver would happily write subtype 3 if told to.
|
||||
case JAVA_LEGACY_READ_ONLY -> UuidRepresentation.JAVA_LEGACY;
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user