From 1ab9e5abd40eb57c6b094efe92f2f01b4982c0c4 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 13:30:58 -0700 Subject: [PATCH 01/44] Preserve framework corrections and durable JDBC batch candidate --- .github/workflows/ci.yml | 4 + README.md | 4 +- build/bom/README.md | 2 +- build/bom/pom.xml | 9 +- build/parent/README.md | 2 +- build/parent/pom.xml | 17 +- docs/examples/getting-started/pom.xml | 2 +- docs/examples/onboarding-coordination/pom.xml | 2 +- docs/examples/onboarding-event-store/pom.xml | 2 +- docs/migration/README.md | 2 +- docs/migration/boot4-application-guide.md | 2 + docs/migration/design.md | 2 +- docs/migration/plan.md | 2 +- docs/releases.md | 2 +- docs/site/guides/onboarding-coordination.md | 2 +- docs/site/installation.md | 2 +- docs/site/maven.md | 4 +- .../modules/agentic-bridge-autoconfigure.md | 2 +- docs/site/modules/agentic-bridge-core.md | 2 +- docs/site/modules/agentic-bridge-starter.md | 2 +- docs/site/modules/agentic-bridge.md | 2 +- docs/site/modules/backoffice.md | 2 +- docs/site/modules/bom.md | 2 +- docs/site/modules/cache-hazelcast.md | 2 +- docs/site/modules/cache-jcache.md | 2 +- docs/site/modules/cache-postgres.md | 2 +- docs/site/modules/cache-redis.md | 2 +- docs/site/modules/cache.md | 2 +- docs/site/modules/callbacks-core.md | 2 +- docs/site/modules/callbacks-interfaces.md | 2 +- docs/site/modules/callbacks-models.md | 2 +- docs/site/modules/callbacks-sdk.md | 2 +- docs/site/modules/callbacks-web.md | 2 +- docs/site/modules/callbacks.md | 2 +- docs/site/modules/client.md | 2 +- docs/site/modules/config-server.md | 2 +- docs/site/modules/cqrs.md | 14 +- .../site/modules/ecm-esignature-adobe-sign.md | 2 +- docs/site/modules/ecm-esignature-docusign.md | 2 +- docs/site/modules/ecm-esignature-logalty.md | 2 +- docs/site/modules/ecm-storage-aws.md | 2 +- docs/site/modules/ecm-storage-azure.md | 2 +- docs/site/modules/ecm.md | 2 +- docs/site/modules/eda-kafka.md | 2 +- docs/site/modules/eda-postgres.md | 2 +- docs/site/modules/eda-rabbitmq.md | 2 +- docs/site/modules/eda.md | 2 +- docs/site/modules/eventsourcing.md | 2 +- docs/site/modules/kernel.md | 2 +- docs/site/modules/notifications-core.md | 2 +- docs/site/modules/notifications-firebase.md | 2 +- docs/site/modules/notifications-resend.md | 2 +- docs/site/modules/notifications-sendgrid.md | 2 +- docs/site/modules/notifications-twilio.md | 2 +- docs/site/modules/notifications.md | 2 +- docs/site/modules/observability.md | 2 +- docs/site/modules/orchestration.md | 19 +- docs/site/modules/parent.md | 2 +- docs/site/modules/plugin-api.md | 2 +- docs/site/modules/plugin-core.md | 2 +- docs/site/modules/plugins.md | 2 +- docs/site/modules/r2dbc.md | 19 +- docs/site/modules/rule-engine-core.md | 2 +- docs/site/modules/rule-engine-interfaces.md | 2 +- docs/site/modules/rule-engine-models.md | 2 +- docs/site/modules/rule-engine-sdk.md | 2 +- docs/site/modules/rule-engine-web.md | 2 +- docs/site/modules/rule-engine.md | 2 +- docs/site/modules/security-adapter-cerbos.md | 2 +- docs/site/modules/security-adapter-opa.md | 2 +- docs/site/modules/security-adapter-openfga.md | 2 +- docs/site/modules/security-adapter-r2dbc.md | 2 +- docs/site/modules/security-adapter-vault.md | 2 +- docs/site/modules/security-api.md | 4 +- .../modules/security-authorization-server.md | 2 +- docs/site/modules/security-bff.md | 2 +- docs/site/modules/security-core.md | 4 +- docs/site/modules/security-idp-aws-cognito.md | 2 +- docs/site/modules/security-idp-azure-ad.md | 2 +- docs/site/modules/security-idp-internal-db.md | 2 +- docs/site/modules/security-idp-keycloak.md | 2 +- docs/site/modules/security-idp.md | 2 +- docs/site/modules/security-method-policy.md | 2 +- docs/site/modules/security-oauth2-client.md | 2 +- docs/site/modules/security-resource-server.md | 25 +- docs/site/modules/security-spi.md | 2 +- docs/site/modules/security-test.md | 4 +- docs/site/modules/security-webflux.md | 2 +- docs/site/modules/starter-application.md | 2 +- docs/site/modules/starter-core.md | 2 +- docs/site/modules/starter-data.md | 2 +- docs/site/modules/starter-domain.md | 2 +- docs/site/modules/utils.md | 23 +- docs/site/modules/validators.md | 2 +- docs/site/modules/web-idempotency.md | 57 ++ docs/site/modules/web.md | 42 +- docs/site/modules/webhooks-core.md | 2 +- docs/site/modules/webhooks-interfaces.md | 2 +- docs/site/modules/webhooks-processor.md | 2 +- docs/site/modules/webhooks-sdk.md | 2 +- docs/site/modules/webhooks-web.md | 2 +- docs/site/modules/webhooks.md | 2 +- docs/site/releases.md | 2 +- docs/site/versioning.md | 6 +- mkdocs.yml | 1 + modules/agentic-bridge/README.md | 2 +- modules/agentic-bridge/docs/QUICK_START.md | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- .../pom.xml | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- modules/agentic-bridge/pom.xml | 4 +- modules/backoffice/README.md | 2 +- modules/backoffice/pom.xml | 4 +- modules/batch/README.md | 127 +++++ modules/batch/pom.xml | 100 ++++ .../DurableBatchAutoConfiguration.java | 131 +++++ .../autoconfigure/DurableBatchProperties.java | 59 +++ .../DurableJdbcBatchAutoConfiguration.java | 47 ++ .../autoconfigure/JdbcBatchConfiguration.java | 66 +++ .../autoconfigure/JdbcBatchResources.java | 83 +++ .../JdbcBatchSchemaValidator.java | 148 ++++++ .../batch/jdbc/JdbcBatchSteps.java | 49 ++ ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../batch/jdbc/schema-postgresql-6.0.5.sql | 78 +++ .../DurableBatchAutoConfigurationTest.java | 115 ++++ .../DurableBatchBootCoexistenceTest.java | 248 +++++++++ .../JdbcBatchResourceSelectionTest.java | 161 ++++++ .../autoconfigure/JdbcBatchSchemaTest.java | 186 +++++++ .../autoconfigure/MinimalBootRuntimeTest.java | 67 +++ .../autoconfigure/MinimalPackagedBootIT.java | 119 +++++ .../batch/fixture/BatchProcessMain.java | 131 +++++ .../batch/fixture/CheckpointJobFixture.java | 176 +++++++ .../batch/fixture/MinimalBootMain.java | 56 ++ .../batch/fixture/OwnedRunFixture.java | 199 +++++++ .../batch/fixture/PostgresBatchFixture.java | 121 +++++ .../project/ScannedNativeProviders.java | 60 +++ .../batch/jdbc/JdbcBatchConcurrencyTest.java | 157 ++++++ .../jdbc/JdbcBatchProcessDatabaseIT.java | 371 +++++++++++++ .../batch/jdbc/JdbcBatchStepsTest.java | 53 ++ .../jdbc/JdbcCheckpointDatabaseTest.java | 184 +++++++ .../jdbc/OwnedRunBoundaryDatabaseTest.java | 274 ++++++++++ modules/cache-hazelcast/README.md | 2 +- modules/cache-hazelcast/pom.xml | 4 +- modules/cache-jcache/README.md | 2 +- modules/cache-jcache/pom.xml | 4 +- modules/cache-postgres/README.md | 2 +- modules/cache-postgres/pom.xml | 4 +- modules/cache-redis/README.md | 2 +- modules/cache-redis/pom.xml | 4 +- modules/cache/README.md | 2 +- modules/cache/docs/ARCHITECTURE.md | 4 +- modules/cache/docs/AUTO_CONFIGURATION.md | 2 +- modules/cache/docs/OPTIONAL_DEPENDENCIES.md | 4 +- modules/cache/pom.xml | 2 +- modules/callbacks/README.md | 2 +- .../fireflyframework-callbacks-core/README.md | 2 +- .../fireflyframework-callbacks-core/pom.xml | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- .../README.md | 2 +- .../fireflyframework-callbacks-models/pom.xml | 2 +- .../fireflyframework-callbacks-sdk/README.md | 2 +- .../fireflyframework-callbacks-sdk/pom.xml | 2 +- .../fireflyframework-callbacks-web/README.md | 2 +- .../fireflyframework-callbacks-web/pom.xml | 2 +- modules/callbacks/pom.xml | 4 +- modules/client/README.md | 2 +- .../client/examples/complete-example/pom.xml | 4 +- modules/client/pom.xml | 4 +- modules/config-server/README.md | 2 +- modules/config-server/pom.xml | 4 +- modules/cqrs/README.md | 4 +- .../cqrs/docs/EVENT_SOURCING_INTEGRATION.md | 4 +- modules/cqrs/pom.xml | 4 +- .../cqrs/query/DefaultQueryBus.java | 69 +-- .../fireflyframework/cqrs/query/Query.java | 35 +- .../cqrs/query/QueryHandler.java | 2 +- .../cqrs/util/GenericTypeResolver.java | 133 ++--- .../integration/QueryCacheIsolationTest.java | 123 +++++ .../cqrs/util/GenericTypeResolverTest.java | 133 +++++ modules/ecm-esignature-adobe-sign/README.md | 2 +- modules/ecm-esignature-adobe-sign/pom.xml | 4 +- modules/ecm-esignature-docusign/README.md | 2 +- modules/ecm-esignature-docusign/pom.xml | 4 +- modules/ecm-esignature-logalty/README.md | 2 +- modules/ecm-esignature-logalty/pom.xml | 4 +- modules/ecm-storage-aws/README.md | 2 +- modules/ecm-storage-aws/pom.xml | 4 +- modules/ecm-storage-azure/README.md | 2 +- modules/ecm-storage-azure/pom.xml | 4 +- modules/ecm/README.md | 2 +- .../adapters/docusign-integration-guide.md | 4 +- .../ecm/docs/adapters/s3-integration-guide.md | 4 +- modules/ecm/docs/configuration-reference.md | 6 +- modules/ecm/docs/configuration.md | 4 +- modules/ecm/docs/guides/azure-integration.md | 2 +- modules/ecm/pom.xml | 4 +- modules/eda-kafka/README.md | 2 +- modules/eda-kafka/pom.xml | 4 +- modules/eda-postgres/README.md | 2 +- modules/eda-postgres/pom.xml | 4 +- modules/eda-rabbitmq/README.md | 2 +- modules/eda-rabbitmq/pom.xml | 4 +- modules/eda/README.md | 2 +- modules/eda/pom.xml | 2 +- modules/eventsourcing/README.md | 2 +- modules/eventsourcing/docs/quick-start.md | 2 +- modules/eventsourcing/pom.xml | 4 +- modules/kernel/README.md | 2 +- modules/kernel/pom.xml | 4 +- modules/notifications-firebase/README.md | 2 +- modules/notifications-firebase/pom.xml | 4 +- modules/notifications-resend/README.md | 2 +- modules/notifications-resend/pom.xml | 4 +- modules/notifications-sendgrid/README.md | 2 +- modules/notifications-sendgrid/pom.xml | 4 +- modules/notifications-twilio/README.md | 2 +- modules/notifications-twilio/pom.xml | 4 +- modules/notifications/README.md | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- modules/notifications/pom.xml | 4 +- modules/observability/README.md | 2 +- modules/observability/pom.xml | 4 +- modules/orchestration/README.md | 19 +- modules/orchestration/docs/configuration.md | 6 + modules/orchestration/docs/foundations.md | 2 +- modules/orchestration/docs/tcc.md | 25 + modules/orchestration/docs/tutorial.md | 2 +- modules/orchestration/pom.xml | 2 +- .../config/OrchestrationProperties.java | 6 + .../config/TccAutoConfiguration.java | 11 +- .../exception/OrchestrationException.java | 2 +- .../exception/TccPersistenceException.java | 53 ++ .../tcc/composition/TccCompositor.java | 5 +- .../orchestration/tcc/engine/TccEngine.java | 70 ++- .../tcc/engine/TccExecutionOrchestrator.java | 29 +- .../tcc/engine/TccPersistenceMode.java | 23 + .../tcc/engine/TccPersistenceSupport.java | 42 ++ .../config/TccPersistenceModeBootTest.java | 168 ++++++ .../TccPersistenceModeConfigurationTest.java | 85 +++ .../unit/tcc/TccRequiredPersistenceTest.java | 369 +++++++++++++ modules/plugins/README.md | 2 +- modules/plugins/plugin-api/README.md | 2 +- modules/plugins/plugin-api/pom.xml | 2 +- modules/plugins/plugin-core/README.md | 2 +- modules/plugins/plugin-core/pom.xml | 2 +- modules/plugins/pom.xml | 4 +- modules/r2dbc/README.md | 2 +- modules/r2dbc/pom.xml | 4 +- .../core/config/R2dbcAutoConfiguration.java | 8 +- .../R2dbcTransactionAutoConfiguration.java | 11 +- .../core/filters/FilterRequest.java | 54 +- ...bcInfrastructureAutoConfigurationTest.java | 110 ++++ .../FilterRequestMarkerIdentityTest.java | 111 ++++ .../filters/FilterRequestOpenApiTest.java | 60 +++ modules/rule-engine/README.md | 2 +- .../README.md | 2 +- .../fireflyframework-rule-engine-core/pom.xml | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- .../README.md | 2 +- .../fireflyframework-rule-engine-sdk/pom.xml | 2 +- .../README.md | 2 +- .../fireflyframework-rule-engine-web/pom.xml | 2 +- modules/rule-engine/pom.xml | 4 +- modules/security-adapter-cerbos/README.md | 2 +- modules/security-adapter-cerbos/pom.xml | 4 +- modules/security-adapter-opa/README.md | 2 +- modules/security-adapter-opa/pom.xml | 4 +- modules/security-adapter-openfga/README.md | 2 +- modules/security-adapter-openfga/pom.xml | 4 +- modules/security-adapter-r2dbc/README.md | 2 +- modules/security-adapter-r2dbc/pom.xml | 4 +- modules/security-adapter-vault/README.md | 2 +- modules/security-adapter-vault/pom.xml | 4 +- modules/security-api/README.md | 2 +- modules/security-api/pom.xml | 4 +- .../security-authorization-server/README.md | 2 +- modules/security-authorization-server/pom.xml | 4 +- modules/security-bff/README.md | 2 +- modules/security-bff/pom.xml | 4 +- modules/security-core/README.md | 2 +- modules/security-core/pom.xml | 4 +- .../policy/EmbeddedPolicyDecisionAdapter.java | 36 +- ...rictEmbeddedPolicyDecisionAdapterTest.java | 64 +++ modules/security-idp-aws-cognito/README.md | 2 +- modules/security-idp-aws-cognito/pom.xml | 6 +- modules/security-idp-azure-ad/README.md | 2 +- modules/security-idp-azure-ad/pom.xml | 6 +- modules/security-idp-internal-db/README.md | 2 +- modules/security-idp-internal-db/pom.xml | 6 +- modules/security-idp-keycloak/README.md | 2 +- modules/security-idp-keycloak/pom.xml | 6 +- modules/security-idp/README.md | 2 +- modules/security-idp/pom.xml | 4 +- modules/security-method-policy/README.md | 2 +- modules/security-method-policy/pom.xml | 4 +- modules/security-oauth2-client/README.md | 2 +- modules/security-oauth2-client/pom.xml | 9 +- .../client/OAuth2ClientAutoConfiguration.java | 2 +- .../OAuth2ClientAutoConfigurationTest.java | 108 ++++ modules/security-resource-server/README.md | 2 +- .../docs/access-token-validation.md | 23 + modules/security-resource-server/pom.xml | 4 +- .../security/rs/AccessTokenValidators.java | 63 +++ .../rs/ResourceServerAutoConfiguration.java | 21 +- .../security/rs/ResourceServerProperties.java | 7 + .../rs/AccessTokenValidatorsTest.java | 109 ++++ .../rs/ResourceServerIntegrationTest.java | 10 + .../rs/StrictPolicyConfigurationTest.java | 78 +++ .../rs/StrictPolicyHttpIntegrationTest.java | 73 +++ modules/security-spi/README.md | 2 +- modules/security-spi/pom.xml | 4 +- modules/security-test/README.md | 2 +- modules/security-test/pom.xml | 4 +- modules/security-webflux/README.md | 2 +- modules/security-webflux/pom.xml | 4 +- modules/starter-application/README.md | 2 +- modules/starter-application/pom.xml | 4 +- modules/starter-core/README.md | 2 +- modules/starter-core/pom.xml | 4 +- modules/starter-data/README.md | 2 +- modules/starter-data/docs/README.md | 4 +- modules/starter-data/docs/data-jobs/guide.md | 2 +- modules/starter-data/pom.xml | 4 +- modules/starter-domain/README.md | 2 +- modules/starter-domain/pom.xml | 4 +- modules/utils/README.md | 25 +- .../utils/docs/restricted-html-templates.md | 25 + modules/utils/pom.xml | 4 +- .../RestrictedHtmlTemplateRenderer.java | 256 +++++++++ .../template/RestrictedTemplateLimits.java | 32 ++ .../template/RestrictedTemplateResult.java | 27 + .../RestrictedHtmlTemplateRendererTest.java | 143 +++++ modules/utils/tutorials/README.md | 2 +- modules/validators/README.md | 2 +- .../docs/offline-json-schema-validation.md | 48 ++ modules/validators/pom.xml | 9 +- .../schema/OfflineJsonSchemaValidator.java | 241 +++++++++ .../schema/SchemaValidationIssue.java | 5 + .../schema/SchemaValidationResult.java | 11 + .../OfflineJsonSchemaValidatorTest.java | 126 +++++ modules/web/README.md | 2 +- modules/web/pom.xml | 21 +- .../error/handler/GlobalExceptionHandler.java | 33 +- .../config/IdempotencyAutoConfiguration.java | 33 +- .../config/IdempotencyProperties.java | 39 ++ .../filter/IdempotencyWebFilter.java | 493 ++++++++---------- .../web/idempotency/model/CachedResponse.java | 12 + ...AuthenticatedIdempotencyScopeResolver.java | 70 +++ .../scope/IdempotencyScopeResolver.java | 27 + .../config/JsonNullableAutoConfiguration.java | 37 ++ .../openapi/IdempotencyOpenAPICustomizer.java | 2 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../handler/ErrorDiagnosticMetadataTest.java | 76 +++ .../ValidationRejectedValuePrivacyTest.java | 91 ++++ .../IdempotencyAutoConfigurationTest.java | 67 +++ .../IdempotencyHttpIntegrationTest.java | 431 +++++++++++++++ .../filter/IdempotencyWebFilterTest.java | 14 +- .../ScopedIdempotencyWebFilterTest.java | 337 ++++++++++++ .../JsonNullableAutoConfigurationTest.java | 164 ++++++ modules/webhooks/README.md | 2 +- .../fireflyframework-webhooks-core/README.md | 2 +- .../fireflyframework-webhooks-core/pom.xml | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- .../README.md | 2 +- .../pom.xml | 2 +- .../fireflyframework-webhooks-sdk/README.md | 2 +- .../fireflyframework-webhooks-sdk/pom.xml | 2 +- .../fireflyframework-webhooks-web/README.md | 2 +- .../fireflyframework-webhooks-web/pom.xml | 2 +- modules/webhooks/pom.xml | 4 +- pom.xml | 3 +- scripts/github_packages.py | 2 +- scripts/release_tools.py | 6 +- scripts/verify_grpc_alignment.py | 68 +++ tests/release/test_github_packages.py | 34 +- tests/test_migration_contract.py | 27 +- 386 files changed, 8995 insertions(+), 900 deletions(-) create mode 100644 docs/site/modules/web-idempotency.md create mode 100644 modules/batch/README.md create mode 100644 modules/batch/pom.xml create mode 100644 modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableBatchAutoConfiguration.java create mode 100644 modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableBatchProperties.java create mode 100644 modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableJdbcBatchAutoConfiguration.java create mode 100644 modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchConfiguration.java create mode 100644 modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResources.java create mode 100644 modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchSchemaValidator.java create mode 100644 modules/batch/src/main/java/org/fireflyframework/batch/jdbc/JdbcBatchSteps.java create mode 100644 modules/batch/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 modules/batch/src/main/resources/org/fireflyframework/batch/jdbc/schema-postgresql-6.0.5.sql create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/DurableBatchAutoConfigurationTest.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/DurableBatchBootCoexistenceTest.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResourceSelectionTest.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchSchemaTest.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/MinimalBootRuntimeTest.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/MinimalPackagedBootIT.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/fixture/BatchProcessMain.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/fixture/CheckpointJobFixture.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/fixture/MinimalBootMain.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/fixture/OwnedRunFixture.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchFixture.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/fixture/project/ScannedNativeProviders.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchConcurrencyTest.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchStepsTest.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcCheckpointDatabaseTest.java create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/jdbc/OwnedRunBoundaryDatabaseTest.java create mode 100644 modules/cqrs/src/test/java/org/fireflyframework/cqrs/integration/QueryCacheIsolationTest.java create mode 100644 modules/cqrs/src/test/java/org/fireflyframework/cqrs/util/GenericTypeResolverTest.java create mode 100644 modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/exception/TccPersistenceException.java create mode 100644 modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccPersistenceMode.java create mode 100644 modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccPersistenceSupport.java create mode 100644 modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/TccPersistenceModeBootTest.java create mode 100644 modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/TccPersistenceModeConfigurationTest.java create mode 100644 modules/orchestration/src/test/java/org/fireflyframework/orchestration/unit/tcc/TccRequiredPersistenceTest.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/config/R2dbcInfrastructureAutoConfigurationTest.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterRequestMarkerIdentityTest.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterRequestOpenApiTest.java create mode 100644 modules/security-core/src/test/java/org/fireflyframework/security/core/policy/StrictEmbeddedPolicyDecisionAdapterTest.java create mode 100644 modules/security-oauth2-client/src/test/java/org/fireflyframework/security/client/OAuth2ClientAutoConfigurationTest.java create mode 100644 modules/security-resource-server/docs/access-token-validation.md create mode 100644 modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/AccessTokenValidators.java create mode 100644 modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/AccessTokenValidatorsTest.java create mode 100644 modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/StrictPolicyConfigurationTest.java create mode 100644 modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/StrictPolicyHttpIntegrationTest.java create mode 100644 modules/utils/docs/restricted-html-templates.md create mode 100644 modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedHtmlTemplateRenderer.java create mode 100644 modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedTemplateLimits.java create mode 100644 modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedTemplateResult.java create mode 100644 modules/utils/src/test/java/org/fireflyframework/utils/template/RestrictedHtmlTemplateRendererTest.java create mode 100644 modules/validators/docs/offline-json-schema-validation.md create mode 100644 modules/validators/src/main/java/org/fireflyframework/validators/schema/OfflineJsonSchemaValidator.java create mode 100644 modules/validators/src/main/java/org/fireflyframework/validators/schema/SchemaValidationIssue.java create mode 100644 modules/validators/src/main/java/org/fireflyframework/validators/schema/SchemaValidationResult.java create mode 100644 modules/validators/src/test/java/org/fireflyframework/validators/schema/OfflineJsonSchemaValidatorTest.java create mode 100644 modules/web/src/main/java/org/fireflyframework/web/idempotency/scope/AuthenticatedIdempotencyScopeResolver.java create mode 100644 modules/web/src/main/java/org/fireflyframework/web/idempotency/scope/IdempotencyScopeResolver.java create mode 100644 modules/web/src/main/java/org/fireflyframework/web/jackson/config/JsonNullableAutoConfiguration.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/error/handler/ErrorDiagnosticMetadataTest.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/error/handler/ValidationRejectedValuePrivacyTest.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyAutoConfigurationTest.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyHttpIntegrationTest.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/ScopedIdempotencyWebFilterTest.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/jackson/config/JsonNullableAutoConfigurationTest.java create mode 100644 scripts/verify_grpc_alignment.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 598c0c1ff..add274f91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,9 @@ jobs: - name: Compile, test, and install framework libraries if: steps.changes.outputs.java == 'true' run: ./mvnw -B -ntp -Dtest.serialization.protobuf=true -Dtest.serialization.avro=true clean install + - name: Check generator and gRPC runtime alignment + if: steps.changes.outputs.java == 'true' + run: python3 scripts/verify_grpc_alignment.py --maven "$GITHUB_WORKSPACE/mvnw" --report target/grpc-alignment.json - name: Test all maintained standalone examples if: steps.changes.outputs.java == 'true' run: bash scripts/verify_examples.sh @@ -61,5 +64,6 @@ jobs: path: | **/target/surefire-reports/ **/target/failsafe-reports/ + target/grpc-alignment.json if-no-files-found: warn retention-days: 14 diff --git a/README.md b/README.md index 7462172c6..e34b2e280 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Use the framework parent when you want its build and dependency management: org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -124,7 +124,7 @@ Or retain your existing parent and import the independent BOM: org.fireflyframework fireflyframework-bom - 26.10.21-SNAPSHOT + 26.10.22 pom import diff --git a/build/bom/README.md b/build/bom/README.md index 163669672..da95b07a5 100644 --- a/build/bom/README.md +++ b/build/bom/README.md @@ -22,7 +22,7 @@ A team that already has a corporate Maven parent can import this BOM into the on org.fireflyframework fireflyframework-bom - 26.10.21-SNAPSHOT + 26.10.22 pom import diff --git a/build/bom/pom.xml b/build/bom/pom.xml index bdf8bad99..68f7947c2 100644 --- a/build/bom/pom.xml +++ b/build/bom/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-bom - 26.10.21-SNAPSHOT + 26.10.22 pom Firefly Framework - BOM @@ -35,7 +35,7 @@ - 26.10.21-SNAPSHOT + 26.10.22 2.21.7 3.1.7 @@ -295,6 +295,11 @@ fireflyframework-backoffice ${fireflyframework.version} + + org.fireflyframework + fireflyframework-batch + ${fireflyframework.version} + diff --git a/build/parent/README.md b/build/parent/README.md index 3453a5122..a9fa9d07e 100644 --- a/build/parent/README.md +++ b/build/parent/README.md @@ -20,7 +20,7 @@ The onboarding example inherits this parent and keeps its own application versio org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/build/parent/pom.xml b/build/parent/pom.xml index f93322dfc..5d5ef98c8 100644 --- a/build/parent/pom.xml +++ b/build/parent/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 pom Firefly Framework - Parent POM @@ -35,7 +35,7 @@ - 26.10.21-SNAPSHOT + 26.10.22 25 ${java.version} @@ -88,11 +88,12 @@ 2.4.0 - 1.79.0 + 1.83.1 4.33.5 1.12.1 + 0.2.8 3.1.12 @@ -473,6 +474,11 @@ + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + org.apache.avro avro @@ -808,6 +814,11 @@ fireflyframework-backoffice ${fireflyframework.version} + + org.fireflyframework + fireflyframework-batch + ${fireflyframework.version} + org.fireflyframework fireflyframework-ecm diff --git a/docs/examples/getting-started/pom.xml b/docs/examples/getting-started/pom.xml index 830fbb767..3788ff6e8 100644 --- a/docs/examples/getting-started/pom.xml +++ b/docs/examples/getting-started/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 com.example diff --git a/docs/examples/onboarding-coordination/pom.xml b/docs/examples/onboarding-coordination/pom.xml index 258467521..eb4098f14 100644 --- a/docs/examples/onboarding-coordination/pom.xml +++ b/docs/examples/onboarding-coordination/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 com.example diff --git a/docs/examples/onboarding-event-store/pom.xml b/docs/examples/onboarding-event-store/pom.xml index 3f87bb4b9..85dcb388e 100644 --- a/docs/examples/onboarding-event-store/pom.xml +++ b/docs/examples/onboarding-event-store/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 com.example diff --git a/docs/migration/README.md b/docs/migration/README.md index 3ff6fc9a9..514345276 100644 --- a/docs/migration/README.md +++ b/docs/migration/README.md @@ -8,7 +8,7 @@ The organization inventory contained 93 repositories. The Java migration include `pom.xml` is an aggregation POM. `build/parent/pom.xml` supplies consumer build/dependency management; `build/bom/pom.xml` is independent. The parent does not import its own BOM, avoiding a model-resolution cycle. Framework dependency versions in the public parent use `fireflyframework.version`, so an application version such as `1.0.0` does not accidentally change its framework dependencies. The BOM now includes the previously omitted `fireflyframework-security-bff`. -A single concrete `26.10.21-SNAPSHOT` version identifies development. The version updater validates the monthly CalVer train (`YY.MM.PP`, with `PP` as the release counter) and all input XML before writing, updates framework project/parent/dependency properties, preserves external dependency versions, and ignores generated `target` directories. Existing release versions are never overwritten. +A single concrete `26.10.22` version identifies development. The version updater validates the monthly CalVer train (`YY.MM.PP`, with `PP` as the release counter) and all input XML before writing, updates framework project/parent/dependency properties, preserves external dependency versions, and ignores generated `target` directories. Existing release versions are never overwritten. ## Boot 4 compatibility diff --git a/docs/migration/boot4-application-guide.md b/docs/migration/boot4-application-guide.md index 700258b38..f5fe942cc 100644 --- a/docs/migration/boot4-application-guide.md +++ b/docs/migration/boot4-application-guide.md @@ -51,6 +51,8 @@ Testcontainers. Version 2 changes Maven module names. Keep Docker available and Provider integrations. Cloud SDK starters, Springdoc, resilience, and security integrations move with the Boot 4 generation. Verify authentication, connection configuration, and failure handling against your actual providers. +gRPC generation and transport. Keep the generator's `grpc.version` aligned with every resolved `io.grpc` runtime artifact. A consumer can compile while a mixed Netty/core train fails with `AbstractMethodError` during server creation. The parent exposes the same gRPC version selected by its Boot platform. Run `python3 scripts/verify_grpc_alignment.py` from the framework checkout to resolve a real consumer graph. The script also accepts `--maven`, `--settings`, `--repository` and `--report` for an isolated build. Use `protoc` from the selected `protobuf.version`, inspect native generator architecture on the build host, and exercise a generated client over an actual owned transport. A classifier name alone does not establish that the downloaded binary runs on that architecture. + ## Suggested application verification - Compile with JDK 25 and inspect the effective dependency tree. diff --git a/docs/migration/design.md b/docs/migration/design.md index 578329dfe..89772e60a 100644 --- a/docs/migration/design.md +++ b/docs/migration/design.md @@ -6,7 +6,7 @@ User instruction: migrate everything autonomously. Audit scope is the 93 reposit Use a Maven 3.9 reactor at the repository root. Keep the published org.fireflyframework group and every existing artifactId, package, resource, test, license and notice. Put standalone parent and BOM at build/parent and build/bom; put 61 other source repositories at modules/. Preserve their internal multimodule layouts and examples. Root aggregator is org.fireflyframework:fireflyframework-jafly:pom. Standalone parent never imports a BOM that references child artifacts; independent BOM is safe for clients that keep another parent. Copy the framework catalog into parent dependencyManagement using a dedicated concrete fireflyframework.version property, not consuming application's project.version. BOM independently manages every publishable library including security-bff; examples are excluded from publication, not import. -Use one concrete release-train version, initially 26.10.21-SNAPSHOT, preserving established padded CalVer. No rewriting existing 26.07.06 releases. A tested version update tool updates all local framework project/parent/internal dependency versions and the shared property. Release requires exact tag v, non-SNAPSHOT version, successful full verification, deterministic publication inventory. Keep Maven 3 and Java 25. User subsequently requested latest Spring Boot; target stable Boot4.1.1/Cloud2025.1.3 and update compatible dependencies, source APIs and tests. +Use one concrete release-train version, preserving established padded CalVer. The current working train is 26.10.22. No rewriting existing 26.07.06 releases. A tested version update tool updates all local framework project/parent/internal dependency versions and the shared property. Release requires exact tag v, non-SNAPSHOT version, successful full verification, deterministic publication inventory. Keep Maven 3 and Java 25. User subsequently requested latest Spring Boot; target stable Boot4.1.1/Cloud2025.1.3 and update compatible dependencies, source APIs and tests. ## History and completeness diff --git a/docs/migration/plan.md b/docs/migration/plan.md index 28080260b..ec7d42abf 100644 --- a/docs/migration/plan.md +++ b/docs/migration/plan.md @@ -3,7 +3,7 @@ Goal: migrate Java framework into fireflyframework-jafly with original history, compatible Maven artifacts, one reactor/release train, verified CI/CD and downstream cutover. Spec: work/design.md. Execution: inline, autonomous per user instruction; independent final review. -Global constraints: 63 source repos, preserve all original Git history/refs and tracked content; JDK25/Maven3.9; original GAs; concrete 26.10.21-SNAPSHOT; no destructive history rewrite or stable publication without verification. +Global constraints: 63 source repos, preserve all original Git history/refs and tracked content; JDK25/Maven3.9; original GAs; concrete 26.10.22; no destructive history rewrite or stable publication without verification. Review focus: BOM/parent consuming application version leakage; overlooked example POMs; cross-repo parent paths; source branches ahead of default; publication success unsupported by artifact inventory. 1. Pin mirror refs and import all source trees into isolated new local repository. Record manifest and verify all blobs/modes before modifying. Preserve namespaced branch/tag refs and original commits. Verify all 63 imports and refs. diff --git a/docs/releases.md b/docs/releases.md index 647165e3c..e71f4fc8f 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,6 +1,6 @@ # Unified Jafly releases -The monorepo builds one version train. The working version is `26.10.21-SNAPSHOT`. +The monorepo builds one version train. The working source version is `26.10.22`; publication is a separate release-workflow operation. ## Workflows diff --git a/docs/site/guides/onboarding-coordination.md b/docs/site/guides/onboarding-coordination.md index 04c4a4796..60b1ffacf 100644 --- a/docs/site/guides/onboarding-coordination.md +++ b/docs/site/guides/onboarding-coordination.md @@ -24,7 +24,7 @@ Use JDK 25. The full framework installation runs provider-backed tests and needs ./mvnw -B -ntp -f docs/examples/onboarding-coordination/pom.xml clean verify ``` -The six tests need no database, Docker engine, message broker, or provider credentials. The project inherits the framework parent at `26.10.21-SNAPSHOT` and keeps its own application version at `0.1.0-SNAPSHOT`: +The six tests need no database, Docker engine, message broker, or provider credentials. The project inherits the framework parent at `26.10.22` and keeps its own application version at `0.1.0-SNAPSHOT`: ```xml --8<-- "docs/examples/onboarding-coordination/pom.xml" diff --git a/docs/site/installation.md b/docs/site/installation.md index 762636f56..378cd746f 100644 --- a/docs/site/installation.md +++ b/docs/site/installation.md @@ -11,7 +11,7 @@ Use JDK 25 and Maven 3.9 to build a Jafly application. Choose a verified registr | Git | Check out the source and runnable examples | | Docker engine | Run the full framework's container-backed tests; the first-service application needs no external services | -The tutorial uses `26.10.21-SNAPSHOT`, which you can install from source. Before using a registry, check that the selected release has verified artifacts there. A version in a source POM does not confirm publication. +The tutorial uses `26.10.22`, which you can install from source. Before using a registry, check that the selected release has verified artifacts there. A version in a source POM does not confirm publication. ## Check the toolchain diff --git a/docs/site/maven.md b/docs/site/maven.md index ba650a774..8b838baeb 100644 --- a/docs/site/maven.md +++ b/docs/site/maven.md @@ -20,7 +20,7 @@ Use the framework parent to adopt its build management, or import the BOM while org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 @@ -50,7 +50,7 @@ Import the framework BOM into your dependency management: org.fireflyframework fireflyframework-bom - 26.10.21-SNAPSHOT + 26.10.22 pom import diff --git a/docs/site/modules/agentic-bridge-autoconfigure.md b/docs/site/modules/agentic-bridge-autoconfigure.md index 022e882dc..8792cdc63 100644 --- a/docs/site/modules/agentic-bridge-autoconfigure.md +++ b/docs/site/modules/agentic-bridge-autoconfigure.md @@ -24,7 +24,7 @@ Configure the primary agent endpoint and authentication for the reviewer-assista org.fireflyframework fireflyframework-agentic-bridge-autoconfigure - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/agentic-bridge-core.md b/docs/site/modules/agentic-bridge-core.md index 1e3f78e72..8b0b636e4 100644 --- a/docs/site/modules/agentic-bridge-core.md +++ b/docs/site/modules/agentic-bridge-core.md @@ -24,7 +24,7 @@ Call a remote agent from the onboarding service when a configured assistant is n org.fireflyframework fireflyframework-agentic-bridge-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/agentic-bridge-starter.md b/docs/site/modules/agentic-bridge-starter.md index 4df3a1635..d04b8a421 100644 --- a/docs/site/modules/agentic-bridge-starter.md +++ b/docs/site/modules/agentic-bridge-starter.md @@ -24,7 +24,7 @@ Add this bundle if the onboarding service needs Spring-managed agent client/brid org.fireflyframework fireflyframework-agentic-bridge-starter - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/agentic-bridge.md b/docs/site/modules/agentic-bridge.md index ceef54bc6..16ee1f413 100644 --- a/docs/site/modules/agentic-bridge.md +++ b/docs/site/modules/agentic-bridge.md @@ -51,7 +51,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-agentic-bridge-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/backoffice.md b/docs/site/modules/backoffice.md index 83576cf3e..4525cb685 100644 --- a/docs/site/modules/backoffice.md +++ b/docs/site/modules/backoffice.md @@ -24,7 +24,7 @@ A reviewer portal can use backoffice contexts and controller bases to expose int org.fireflyframework fireflyframework-backoffice - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/bom.md b/docs/site/modules/bom.md index 3a24d3d6d..e78a5cdc0 100644 --- a/docs/site/modules/bom.md +++ b/docs/site/modules/bom.md @@ -26,7 +26,7 @@ A team that already has a corporate Maven parent can import this BOM into the on org.fireflyframework fireflyframework-bom - 26.10.21-SNAPSHOT + 26.10.22 pom import diff --git a/docs/site/modules/cache-hazelcast.md b/docs/site/modules/cache-hazelcast.md index 2df1ef6bd..5f0cb135a 100644 --- a/docs/site/modules/cache-hazelcast.md +++ b/docs/site/modules/cache-hazelcast.md @@ -24,7 +24,7 @@ If the onboarding deployment already supplies a Hazelcast instance, this provide org.fireflyframework fireflyframework-cache-hazelcast - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/cache-jcache.md b/docs/site/modules/cache-jcache.md index 351b99a72..44667edb7 100644 --- a/docs/site/modules/cache-jcache.md +++ b/docs/site/modules/cache-jcache.md @@ -24,7 +24,7 @@ Use this adapter when the onboarding host supplies a JCache CacheManager. The ap org.fireflyframework fireflyframework-cache-jcache - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/cache-postgres.md b/docs/site/modules/cache-postgres.md index 9fce595d9..133602bb4 100644 --- a/docs/site/modules/cache-postgres.md +++ b/docs/site/modules/cache-postgres.md @@ -24,7 +24,7 @@ A service that already operates PostgreSQL can use a separate cache table for re org.fireflyframework fireflyframework-cache-postgres - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/cache-redis.md b/docs/site/modules/cache-redis.md index 7cd915646..4a9ae1d42 100644 --- a/docs/site/modules/cache-redis.md +++ b/docs/site/modules/cache-redis.md @@ -24,7 +24,7 @@ Use a Redis-backed cache when several onboarding-service instances need the same org.fireflyframework fireflyframework-cache-redis - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/cache.md b/docs/site/modules/cache.md index ed4268d94..1b6ed5ff5 100644 --- a/docs/site/modules/cache.md +++ b/docs/site/modules/cache.md @@ -38,7 +38,7 @@ A reviewer portal may read the same application status repeatedly. Cache those r org.fireflyframework fireflyframework-cache - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/callbacks-core.md b/docs/site/modules/callbacks-core.md index 202a9f2db..ee98d0ba5 100644 --- a/docs/site/modules/callbacks-core.md +++ b/docs/site/modules/callbacks-core.md @@ -24,7 +24,7 @@ Route a configured application.submitted or application.reviewed event to author org.fireflyframework fireflyframework-callbacks-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/callbacks-interfaces.md b/docs/site/modules/callbacks-interfaces.md index e5ac0b39a..450c758b8 100644 --- a/docs/site/modules/callbacks-interfaces.md +++ b/docs/site/modules/callbacks-interfaces.md @@ -24,7 +24,7 @@ Use these DTOs to configure a partner callback for application-status events. Cr org.fireflyframework fireflyframework-callbacks-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/callbacks-models.md b/docs/site/modules/callbacks-models.md index 021652dea..ca966f57d 100644 --- a/docs/site/modules/callbacks-models.md +++ b/docs/site/modules/callbacks-models.md @@ -24,7 +24,7 @@ The callback service stores destinations, subscriptions, and executions separate org.fireflyframework fireflyframework-callbacks-models - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/callbacks-sdk.md b/docs/site/modules/callbacks-sdk.md index 6f68f4f2a..1dca27915 100644 --- a/docs/site/modules/callbacks-sdk.md +++ b/docs/site/modules/callbacks-sdk.md @@ -24,7 +24,7 @@ A partner-management client can use this generated SDK to register or inspect ca org.fireflyframework fireflyframework-callbacks-sdk - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/callbacks-web.md b/docs/site/modules/callbacks-web.md index 267bf7865..f5d5b5409 100644 --- a/docs/site/modules/callbacks-web.md +++ b/docs/site/modules/callbacks-web.md @@ -24,7 +24,7 @@ Operate this service if onboarding partners manage callbacks through HTTP. Confi org.fireflyframework fireflyframework-callbacks-web - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/callbacks.md b/docs/site/modules/callbacks.md index 60fba2f7e..8c261cddc 100644 --- a/docs/site/modules/callbacks.md +++ b/docs/site/modules/callbacks.md @@ -49,7 +49,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-callbacks-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/client.md b/docs/site/modules/client.md index 114eeece1..3c64b0fd3 100644 --- a/docs/site/modules/client.md +++ b/docs/site/modules/client.md @@ -24,7 +24,7 @@ The service may call a document-verification partner during a later onboarding s org.fireflyframework fireflyframework-client - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/config-server.md b/docs/site/modules/config-server.md index 7ff0bf5c3..464f47100 100644 --- a/docs/site/modules/config-server.md +++ b/docs/site/modules/config-server.md @@ -24,7 +24,7 @@ When several onboarding services share centrally managed configuration, operate org.fireflyframework fireflyframework-config-server - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/cqrs.md b/docs/site/modules/cqrs.md index 5b4eaa3c9..a4883af2d 100644 --- a/docs/site/modules/cqrs.md +++ b/docs/site/modules/cqrs.md @@ -36,7 +36,7 @@ Commands and queries have different handlers and policies. The buses connect app org.fireflyframework fireflyframework-cqrs - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -124,6 +124,8 @@ Use the returned `id` in `GET /applications/{id}`. Run the project's tests with: The command/query interfaces allow execution by default. Protected operations need an authorization rule and trusted identity context. Jakarta annotation validation needs a `Validator` bean; the example declares the validation starter. +`ContextAwareCommandHandler` and `ContextAwareQueryHandler` support handlers that require the bus's `ExecutionContext`. Bind their message and result types in a concrete subclass. Type resolution follows generic bindings through intermediate classes, reordered parameters, interfaces and standard generated subclasses; raw or unbound message variables do not become permission to dispatch every command/query through their upper bound. Construction still rejects an unresolved handler type. Parameterized result types expose their raw Java class for dispatch metadata, rather than a complete serialization schema. Context propagation does not authenticate an arbitrary context: populate identity through a trusted application boundary and enforce the operation's authorization policy. + Publishing a command result requires EDA, an `EventPublisherFactory` bean, the enabled CQRS/EDA bridge, and `@PublishDomainEvent` on the handler. Query invalidation requires a `QueryCacheAdapter` and a matching `@InvalidateCacheOn` event delivered through Spring's application-event path. Connect broker events to that local path explicitly. The onboarding tutorial configures neither bridge. These annotation declarations are API fragments for an application that has added that infrastructure: @@ -230,6 +232,14 @@ void shouldCreateFailedAuthorizationResultWithSingleError() { ## Detailed reference +### Explicit query cache identity + +`Query.getCacheKey()` returns `null` by default, so a handler executes without a cached result unless the query explicitly supplies a key. The former class-name/metadata-hash default could collapse different resources into the same entry. Updating the framework stops reading those implicit entries; existing explicit custom keys keep their own semantics and must be reviewed separately. + +A custom key must represent all result-affecting inputs and the relevant trusted tenant, subject, and policy scope, including resource identity, filters, pagination, and version where applicable. Do not put credentials or personal data in keys. If an execution context changes the result, either include its trusted scope in the query's explicit key or keep that handler uncached. The bus does not infer a complete authority boundary from an arbitrary context map. + +Both `query(query)` and `query(query, context)` require the query and handler to allow caching and a non-null explicit key. Validation and configured authorization precede cache access. `@QueryHandlerComponent(cacheable = false)` bypasses both cache reads and writes, including when a matching stale entry exists. Prefer that setting for authoritative workflow status/result reads without a qualified invalidation and freshness policy. + ### Configuration All properties live under the `firefly.cqrs.*` namespace. The values below are the configured defaults; you only need to set what you want to change. @@ -244,7 +254,7 @@ firefly: tracing-enabled: true query: timeout: 15s # default query processing timeout - caching-enabled: true # enable query-result caching by default + caching-enabled: true # provide caching; each query still needs an explicit key cache-ttl: 15m # default TTL for the dedicated CQRS query cache metrics-enabled: true tracing-enabled: true diff --git a/docs/site/modules/ecm-esignature-adobe-sign.md b/docs/site/modules/ecm-esignature-adobe-sign.md index db6506f5d..a13af4218 100644 --- a/docs/site/modules/ecm-esignature-adobe-sign.md +++ b/docs/site/modules/ecm-esignature-adobe-sign.md @@ -24,7 +24,7 @@ If onboarding includes a signed agreement, use the Adobe Sign adapter for the co org.fireflyframework fireflyframework-ecm-esignature-adobe-sign - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/ecm-esignature-docusign.md b/docs/site/modules/ecm-esignature-docusign.md index 53123f175..bcbe05f56 100644 --- a/docs/site/modules/ecm-esignature-docusign.md +++ b/docs/site/modules/ecm-esignature-docusign.md @@ -24,7 +24,7 @@ If onboarding includes a signed agreement, use the DocuSign adapter for the conf org.fireflyframework fireflyframework-ecm-esignature-docusign - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/ecm-esignature-logalty.md b/docs/site/modules/ecm-esignature-logalty.md index 8e213add2..3b3b976b0 100644 --- a/docs/site/modules/ecm-esignature-logalty.md +++ b/docs/site/modules/ecm-esignature-logalty.md @@ -24,7 +24,7 @@ If onboarding includes a signed agreement, use the Logalty adapter for the confi org.fireflyframework fireflyframework-ecm-esignature-logalty - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/ecm-storage-aws.md b/docs/site/modules/ecm-storage-aws.md index 362859901..734edd296 100644 --- a/docs/site/modules/ecm-storage-aws.md +++ b/docs/site/modules/ecm-storage-aws.md @@ -24,7 +24,7 @@ Store an applicant's supporting document in Amazon S3 through the ECM content po org.fireflyframework fireflyframework-ecm-storage-aws - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/ecm-storage-azure.md b/docs/site/modules/ecm-storage-azure.md index 28a77db40..44ed5a9f5 100644 --- a/docs/site/modules/ecm-storage-azure.md +++ b/docs/site/modules/ecm-storage-azure.md @@ -24,7 +24,7 @@ Store an applicant's supporting document in Azure Blob Storage through the ECM c org.fireflyframework fireflyframework-ecm-storage-azure - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/ecm.md b/docs/site/modules/ecm.md index b1701e96d..c40d1d4ef 100644 --- a/docs/site/modules/ecm.md +++ b/docs/site/modules/ecm.md @@ -37,7 +37,7 @@ An applicant may upload supporting documents or sign an agreement after submissi org.fireflyframework fireflyframework-ecm - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/eda-kafka.md b/docs/site/modules/eda-kafka.md index f6c6036d0..737135868 100644 --- a/docs/site/modules/eda-kafka.md +++ b/docs/site/modules/eda-kafka.md @@ -24,7 +24,7 @@ Publish application lifecycle events to Kafka when a separate review worker cons org.fireflyframework fireflyframework-eda-kafka - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/eda-postgres.md b/docs/site/modules/eda-postgres.md index beb2b57a7..b65f7b0db 100644 --- a/docs/site/modules/eda-postgres.md +++ b/docs/site/modules/eda-postgres.md @@ -24,7 +24,7 @@ A PostgreSQL delivery table can carry application lifecycle events without a sep org.fireflyframework fireflyframework-eda-postgres - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/eda-rabbitmq.md b/docs/site/modules/eda-rabbitmq.md index c1a045a56..c37b9cbe3 100644 --- a/docs/site/modules/eda-rabbitmq.md +++ b/docs/site/modules/eda-rabbitmq.md @@ -24,7 +24,7 @@ RabbitMQ can route application lifecycle events to the configured review queues. org.fireflyframework fireflyframework-eda-rabbitmq - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/eda.md b/docs/site/modules/eda.md index 3b429185b..8814997c3 100644 --- a/docs/site/modules/eda.md +++ b/docs/site/modules/eda.md @@ -24,7 +24,7 @@ Once a submitted application must notify another service, define an application. org.fireflyframework fireflyframework-eda - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/eventsourcing.md b/docs/site/modules/eventsourcing.md index 26b4e30ef..d1165753d 100644 --- a/docs/site/modules/eventsourcing.md +++ b/docs/site/modules/eventsourcing.md @@ -24,7 +24,7 @@ If the product needs application history as the source of truth, model submitted org.fireflyframework fireflyframework-eventsourcing - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/kernel.md b/docs/site/modules/kernel.md index 76c3d84a2..9663d122f 100644 --- a/docs/site/modules/kernel.md +++ b/docs/site/modules/kernel.md @@ -24,7 +24,7 @@ A rejected application and an unavailable verification partner need different er org.fireflyframework fireflyframework-kernel - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/notifications-core.md b/docs/site/modules/notifications-core.md index b8a43f6fc..6c1705f69 100644 --- a/docs/site/modules/notifications-core.md +++ b/docs/site/modules/notifications-core.md @@ -24,7 +24,7 @@ The onboarding service can use EmailService to confirm receipt or request missin org.fireflyframework fireflyframework-notifications-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/notifications-firebase.md b/docs/site/modules/notifications-firebase.md index 375046003..4eb3b2520 100644 --- a/docs/site/modules/notifications-firebase.md +++ b/docs/site/modules/notifications-firebase.md @@ -24,7 +24,7 @@ A mobile applicant portal can receive push notifications through Firebase. The a org.fireflyframework fireflyframework-notifications-firebase - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/notifications-resend.md b/docs/site/modules/notifications-resend.md index 1e4ecf195..7d74ad729 100644 --- a/docs/site/modules/notifications-resend.md +++ b/docs/site/modules/notifications-resend.md @@ -24,7 +24,7 @@ Resend is another email provider for application confirmations. Select it throug org.fireflyframework fireflyframework-notifications-resend - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/notifications-sendgrid.md b/docs/site/modules/notifications-sendgrid.md index 0407f8491..c129a8c3e 100644 --- a/docs/site/modules/notifications-sendgrid.md +++ b/docs/site/modules/notifications-sendgrid.md @@ -24,7 +24,7 @@ SendGrid can deliver application confirmations through the email provider port. org.fireflyframework fireflyframework-notifications-sendgrid - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/notifications-twilio.md b/docs/site/modules/notifications-twilio.md index 0554e5365..a1d9ac546 100644 --- a/docs/site/modules/notifications-twilio.md +++ b/docs/site/modules/notifications-twilio.md @@ -24,7 +24,7 @@ Use the Twilio SMS provider for an onboarding message when the product has colle org.fireflyframework fireflyframework-notifications-twilio - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/notifications.md b/docs/site/modules/notifications.md index 632286c8d..09d2a719b 100644 --- a/docs/site/modules/notifications.md +++ b/docs/site/modules/notifications.md @@ -49,7 +49,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-notifications-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/observability.md b/docs/site/modules/observability.md index bd4820688..89ce0f5a8 100644 --- a/docs/site/modules/observability.md +++ b/docs/site/modules/observability.md @@ -24,7 +24,7 @@ Measure application submissions and review latency with the metrics support, and org.fireflyframework fireflyframework-observability - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/orchestration.md b/docs/site/modules/orchestration.md index 8eb2bc31b..9e069242b 100644 --- a/docs/site/modules/orchestration.md +++ b/docs/site/modules/orchestration.md @@ -39,7 +39,7 @@ An extended onboarding flow might verify documents, wait for a review, and provi org.fireflyframework fireflyframework-orchestration - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -138,6 +138,23 @@ tccEngine.execute(def, TccInputs.of(Map.of("debit", request, "credit", request)) }); ``` +### Require TCC persistence acknowledgments + +`BEST_EFFORT` is the default. Select `REQUIRED` when an execution must stop if its configured persistence provider does not acknowledge a save: + +```yaml +firefly: + orchestration: + tcc: + persistence-mode: REQUIRED +``` + +The native configuration gives the engine and orchestrator the same policy/provider. Manual assembly must do so explicitly; REQUIRED rejects absent or mismatched providers/policies. An initial save failure invokes no participant. A required checkpoint or final-save failure propagates `TccPersistenceException` with its correlation ID, save stage, attempted status, original cause, possible-effects flag and retained execution context. It cannot become a confirmed result, an error-suppressed success or an automatic compensation request. + +Overall completion observations, execution-completed publication and success callbacks—including asynchronous callback subscription—wait for the final acknowledgment. Participant/phase events still describe calls that already occurred and do not establish overall financial completion. Preserve the original operation/resource IDs when handling uncertainty; do not blindly retry execution or compensate based only on this error. + +An acknowledged save means the configured publisher completed. The default in-memory provider does not gain disk durability through this setting. Existing phase snapshots are not a participant journal or a durable pause/resume protocol: a CONFIRMED phase checkpoint can exist without an acknowledged final report. Actual provider storage, restart recovery and the product's financial action protocol need separate qualification. Existing participant-error cancellation rules remain; define participant timeout/uncertain-outcome semantics before using the example for money movement. + ### Workflow ```java diff --git a/docs/site/modules/parent.md b/docs/site/modules/parent.md index 6d876e953..446978c62 100644 --- a/docs/site/modules/parent.md +++ b/docs/site/modules/parent.md @@ -24,7 +24,7 @@ The onboarding example inherits this parent and keeps its own application versio org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/plugin-api.md b/docs/site/modules/plugin-api.md index dbe3cd6b3..541a1f78b 100644 --- a/docs/site/modules/plugin-api.md +++ b/docs/site/modules/plugin-api.md @@ -24,7 +24,7 @@ Define a host extension contract for an onboarding check and implement it in an org.fireflyframework plugin-api - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/plugin-core.md b/docs/site/modules/plugin-core.md index 4ea8837d8..359d1fcd7 100644 --- a/docs/site/modules/plugin-core.md +++ b/docs/site/modules/plugin-core.md @@ -24,7 +24,7 @@ The onboarding host can load an application plugin class and start its contribut org.fireflyframework plugin-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/plugins.md b/docs/site/modules/plugins.md index bfc16f2d8..6578a8209 100644 --- a/docs/site/modules/plugins.md +++ b/docs/site/modules/plugins.md @@ -47,7 +47,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework plugin-api - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/r2dbc.md b/docs/site/modules/r2dbc.md index 5ba6a75d3..bcf836758 100644 --- a/docs/site/modules/r2dbc.md +++ b/docs/site/modules/r2dbc.md @@ -24,7 +24,7 @@ The tutorial saves applications in a process-local map. An application-owned R2D org.fireflyframework fireflyframework-r2dbc - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -185,6 +185,23 @@ Both beans use `matchIfMissing = true`, so they are active by default and back o This module ships an `application-logging.yml` profile fragment that sets `org.springframework.data.r2dbc` and `io.r2dbc` logging to `INFO`. +### Conditional database infrastructure + +R2DBC utility types can be present transitively in a service that owns no database. Their presence does not require that service to create a connection factory, entity template, or transaction manager. In this source version, database integration activates only when its runtime prerequisites exist: + +| Available infrastructure | Framework behavior | +| --- | --- | +| No R2DBC/transaction runtime classes | Database auto-configuration backs off before loading its dependent beans | +| Classes present, no entity template or connection factory | No database template initialization or transaction-manager creation | +| Application or Boot provides `R2dbcEntityTemplate` | Initialize `FilterUtils` after Boot's data R2DBC auto-configuration | +| One candidate `ConnectionFactory`, no existing reactive transaction manager | Provide `R2dbcTransactionManager` if Boot has not already supplied one | +| Existing `ReactiveTransactionManager` | Preserve the application's selected manager | +| Several connection factories without a primary candidate | Do not choose an arbitrary database; configure the intended transaction boundary explicitly | + +This conditional behavior concerns framework infrastructure. It does not disable Spring Boot's own database auto-configuration selected by a data starter. A service that includes that starter must still configure its database or deliberately exclude the unused Boot data configuration. Database-free services should depend only on the facilities they actually use. + +`FilterUtils` uses a process-wide template reference. Applications with multiple independently selected databases must define the supported filtering/database boundary explicitly; these conditions do not introduce multitenant database routing. + ### How It Works The module registers four Spring Boot auto-configurations (see `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`): diff --git a/docs/site/modules/rule-engine-core.md b/docs/site/modules/rule-engine-core.md index b41010306..af7598697 100644 --- a/docs/site/modules/rule-engine-core.md +++ b/docs/site/modules/rule-engine-core.md @@ -24,7 +24,7 @@ Evaluate a validated onboarding rule against application input through the parse org.fireflyframework fireflyframework-rule-engine-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/rule-engine-interfaces.md b/docs/site/modules/rule-engine-interfaces.md index f39853639..27d67674c 100644 --- a/docs/site/modules/rule-engine-interfaces.md +++ b/docs/site/modules/rule-engine-interfaces.md @@ -24,7 +24,7 @@ Choose the request DTO for plain YAML, encoded YAML, or a stored onboarding rule org.fireflyframework fireflyframework-rule-engine-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/rule-engine-models.md b/docs/site/modules/rule-engine-models.md index 879005184..142d0144c 100644 --- a/docs/site/modules/rule-engine-models.md +++ b/docs/site/modules/rule-engine-models.md @@ -24,7 +24,7 @@ Use these repositories when onboarding rules and constants need a stored, versio org.fireflyframework fireflyframework-rule-engine-models - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/rule-engine-sdk.md b/docs/site/modules/rule-engine-sdk.md index 4e2ae8b39..a2b9d1243 100644 --- a/docs/site/modules/rule-engine-sdk.md +++ b/docs/site/modules/rule-engine-sdk.md @@ -24,7 +24,7 @@ A separate onboarding service can call the rule service through generated WebCli org.fireflyframework fireflyframework-rule-engine-sdk - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/rule-engine-web.md b/docs/site/modules/rule-engine-web.md index 7797055d4..805a4341f 100644 --- a/docs/site/modules/rule-engine-web.md +++ b/docs/site/modules/rule-engine-web.md @@ -24,7 +24,7 @@ Run the rule service when onboarding applications evaluate rules over HTTP or ma org.fireflyframework fireflyframework-rule-engine-web - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/rule-engine.md b/docs/site/modules/rule-engine.md index 8224ab2ea..08a38c46f 100644 --- a/docs/site/modules/rule-engine.md +++ b/docs/site/modules/rule-engine.md @@ -51,7 +51,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-rule-engine-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-adapter-cerbos.md b/docs/site/modules/security-adapter-cerbos.md index f5ad6d8bc..9322b98f1 100644 --- a/docs/site/modules/security-adapter-cerbos.md +++ b/docs/site/modules/security-adapter-cerbos.md @@ -24,7 +24,7 @@ A reviewer action can be checked against Cerbos when this adapter is the configu org.fireflyframework fireflyframework-security-adapter-cerbos - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-adapter-opa.md b/docs/site/modules/security-adapter-opa.md index 274879b0e..3f2f4ca13 100644 --- a/docs/site/modules/security-adapter-opa.md +++ b/docs/site/modules/security-adapter-opa.md @@ -24,7 +24,7 @@ Delegate an onboarding authorization decision to a configured OPA policy through org.fireflyframework fireflyframework-security-adapter-opa - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-adapter-openfga.md b/docs/site/modules/security-adapter-openfga.md index 6486b6437..aefa308dc 100644 --- a/docs/site/modules/security-adapter-openfga.md +++ b/docs/site/modules/security-adapter-openfga.md @@ -24,7 +24,7 @@ Use RelationshipPort to check whether a reviewer is related to a particular appl org.fireflyframework fireflyframework-security-adapter-openfga - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-adapter-r2dbc.md b/docs/site/modules/security-adapter-r2dbc.md index 65f432c4d..2c4d1cbe7 100644 --- a/docs/site/modules/security-adapter-r2dbc.md +++ b/docs/site/modules/security-adapter-r2dbc.md @@ -24,7 +24,7 @@ Store revoked token IDs when the application uses PostgreSQL for that security s org.fireflyframework fireflyframework-security-adapter-r2dbc - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-adapter-vault.md b/docs/site/modules/security-adapter-vault.md index 6cc7ee7a9..9fe33d1fd 100644 --- a/docs/site/modules/security-adapter-vault.md +++ b/docs/site/modules/security-adapter-vault.md @@ -24,7 +24,7 @@ Resolve a verification partner's credential through SecretsPort at runtime. Prov org.fireflyframework fireflyframework-security-adapter-vault - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-api.md b/docs/site/modules/security-api.md index 2fe43b4ea..f104d9562 100644 --- a/docs/site/modules/security-api.md +++ b/docs/site/modules/security-api.md @@ -24,7 +24,7 @@ Represent an authenticated applicant or reviewer with SecurityPrincipal and eval org.fireflyframework fireflyframework-security-api - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -208,7 +208,7 @@ Runtime footprint is intentionally tiny (see `pom.xml`): | `org.projectlombok:lombok` | provided | `@Builder` on `SecurityPrincipal`. | | `junit-jupiter`, `assertj-core`, `reactor-test` | test | Unit tests. | -Version and parent are managed by `fireflyframework-parent` (CalVer `26.10.21-SNAPSHOT`); consumers inheriting the parent/BOM can omit the version. +Version and parent are managed by `fireflyframework-parent` (CalVer `26.10.22`); consumers inheriting the parent/BOM can omit the version. ```xml diff --git a/docs/site/modules/security-authorization-server.md b/docs/site/modules/security-authorization-server.md index 30bb79b29..090fee29e 100644 --- a/docs/site/modules/security-authorization-server.md +++ b/docs/site/modules/security-authorization-server.md @@ -24,7 +24,7 @@ Use this separate servlet service when the deployment needs to issue its own OAu org.fireflyframework fireflyframework-security-authorization-server - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-bff.md b/docs/site/modules/security-bff.md index c42d5d626..f4afa4cda 100644 --- a/docs/site/modules/security-bff.md +++ b/docs/site/modules/security-bff.md @@ -24,7 +24,7 @@ A browser applicant portal can keep tokens in a server-side session and present org.fireflyframework fireflyframework-security-bff - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-core.md b/docs/site/modules/security-core.md index ba4701400..3b82885d8 100644 --- a/docs/site/modules/security-core.md +++ b/docs/site/modules/security-core.md @@ -40,7 +40,7 @@ Map the authenticated applicant/reviewer claims and evaluate the configured auth org.fireflyframework fireflyframework-security-core - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -187,7 +187,7 @@ Supporting types: `SecureRequirement` (a framework-neutral view of `@Secure`, bu The engine is deliberately fail-closed at every decision point: - `SecureAuthorizationEvaluator`. a `null` (unauthenticated) principal is denied. Declared dimensions (roles, scopes, permissions, expression) are AND-ed; within a dimension, `requireAll*` selects ALL vs ANY (ANY by default). An empty requirement permits any authenticated principal ("require authentication"). A SpEL expression that evaluates false, or throws / is malformed, denies. -- `EmbeddedPolicyDecisionAdapter`. deny-overrides: any rule DENY wins; rules registered but none permitting yields DENY; a rule that errors becomes INDETERMINATE (never PERMIT). With no rules registered it permits, deferring to RBAC at the PEP (ABAC simply not in use). +- `EmbeddedPolicyDecisionAdapter`. The original constructor preserves legacy deny-overrides semantics and permits with no rules to defer to a separate RBAC gate. The `(List, boolean strict)` overload with `true` denies missing rules, failed/empty publishers, malformed decisions without an effect and explicit denials, requires an explicit permit, and retains permit obligations in rule order. Normal indeterminate abstention may coexist with an explicit permit. An enforcing caller must fulfill returned obligations or deny; a provider bean is not evidence of an institution policy. The resource-server binding selects this overload with `firefly.security.resource-server.strict-policy: true`. - `InMemoryKeyManagementAdapter`. flagged not-for-production; the autoconfigure layer fails closed at startup if no real key source resolves under prod profiles. - `CaffeineIntrospectionCache`. TTL is bounded by the token's own expiry, so a revoked-by-expiry token cannot outlive its `exp`. diff --git a/docs/site/modules/security-idp-aws-cognito.md b/docs/site/modules/security-idp-aws-cognito.md index 655b5cc62..32221d89f 100644 --- a/docs/site/modules/security-idp-aws-cognito.md +++ b/docs/site/modules/security-idp-aws-cognito.md @@ -24,7 +24,7 @@ Use an existing Cognito user pool for applicant identities. Configure its region org.fireflyframework fireflyframework-security-idp-aws-cognito - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-idp-azure-ad.md b/docs/site/modules/security-idp-azure-ad.md index af4432758..bd88811b1 100644 --- a/docs/site/modules/security-idp-azure-ad.md +++ b/docs/site/modules/security-idp-azure-ad.md @@ -24,7 +24,7 @@ Connect applicant or reviewer identity operations to the configured Entra ID or org.fireflyframework fireflyframework-security-idp-azure-ad - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-idp-internal-db.md b/docs/site/modules/security-idp-internal-db.md index eeb0845d5..69bef8db4 100644 --- a/docs/site/modules/security-idp-internal-db.md +++ b/docs/site/modules/security-idp-internal-db.md @@ -24,7 +24,7 @@ Use the internal database adapter when this service owns its identity store. Con org.fireflyframework fireflyframework-security-idp-internal-db - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-idp-keycloak.md b/docs/site/modules/security-idp-keycloak.md index fbcdc065b..e234ef842 100644 --- a/docs/site/modules/security-idp-keycloak.md +++ b/docs/site/modules/security-idp-keycloak.md @@ -24,7 +24,7 @@ Manage applicant identities in the configured Keycloak realm. Set its server and org.fireflyframework fireflyframework-security-idp-keycloak - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-idp.md b/docs/site/modules/security-idp.md index f86acf5dd..6b95dcad7 100644 --- a/docs/site/modules/security-idp.md +++ b/docs/site/modules/security-idp.md @@ -24,7 +24,7 @@ Manage applicant identities through IdpAdapter and a selected provider. Keep ide org.fireflyframework fireflyframework-security-idp - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-method-policy.md b/docs/site/modules/security-method-policy.md index 0c305a35e..94732ca4f 100644 --- a/docs/site/modules/security-method-policy.md +++ b/docs/site/modules/security-method-policy.md @@ -24,7 +24,7 @@ Protect a reactive review method on a Spring bean with the required reviewer aut org.fireflyframework fireflyframework-security-method-policy - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-oauth2-client.md b/docs/site/modules/security-oauth2-client.md index c010d3f84..a0e42e73c 100644 --- a/docs/site/modules/security-oauth2-client.md +++ b/docs/site/modules/security-oauth2-client.md @@ -24,7 +24,7 @@ The onboarding service may need an authorized outbound call to a protected partn org.fireflyframework fireflyframework-security-oauth2-client - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-resource-server.md b/docs/site/modules/security-resource-server.md index 0442df474..0d6878442 100644 --- a/docs/site/modules/security-resource-server.md +++ b/docs/site/modules/security-resource-server.md @@ -24,7 +24,7 @@ Protect onboarding HTTP routes with JWTs from the configured identity authority. org.fireflyframework fireflyframework-security-resource-server - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -36,12 +36,13 @@ This binding configures JWT validation. Opaque-token introspection ports exist e Set `OIDC_ISSUER` to the expected token issuer and `OIDC_JWK_SET_URI` to that provider's JWKS endpoint. For an external identity provider, configure both: `issuer` validates the `iss` claim; it does not discover signing keys. Without `jwk-set-uri`, the decoder uses the configured `KeyManagementPort` signing key instead. -With the module on the classpath, the resource server is active with zero code. Every route is locked down. Mark public routes explicitly and read the validated principal directly: +With the module on the classpath, protected routes require a validated bearer token. Authentication alone does not grant financial or insurance operations. Enable strict policy mode, contribute the current project policy, and mark public routes explicitly: ```yaml firefly: security: resource-server: + strict-policy: true permit-matchers: - /actuator/health - /public/** @@ -80,12 +81,23 @@ PolicyRule denyReports() { } ``` +## Require an explicit current policy + +`firefly.security.resource-server.strict-policy: true` makes the fallback embedded policy deny when no rules are configured. A failed or empty rule publisher, or a decision without an effect, denies even when another rule permits. Normal `INDETERMINATE` results may abstain, but at least one explicit permit is required and any denial wins. The embedded provider retains permit obligations; an enforcing business boundary must fulfill them or deny access. + +The default URL chain cannot perform row filtering, masking, approval, or other policy obligations. In strict mode it rejects obligation-bearing permits and bounds the current policy publisher to five seconds. A project-owned `PolicyDecisionPort` remains the single authority and replaces the fallback provider; its errors, empty results, unresolved obligations, and timeouts cannot authorize an exchange. A custom filter chain must enforce those checks itself and preserve a catchall denial for unmatched routes. + +The property defaults to `false` for compatibility. In that legacy mode, the embedded provider with no rules permits in order to defer to a separate RBAC gate. Do not confuse a default provider bean with an approved current resource decision. Financial and insurance integrations that require strict mode must also reject missing or false configuration at their own business entry point. Public `permit-matchers` remain explicit authentication exceptions; do not put protected business paths there. + +A signed token proves its validated claims. It does not supply tenant ownership, staff impersonation approval, contract authority, a model approval, or permission to retry a financial effect. Bind each business request to its exact trusted principal/resource and evaluate current authority again on replay and recovery. Keep actor selection out of untrusted headers and request bodies. + ## Configuration and wiring The `firefly.security.resource-server` namespace is bound by [ResourceServerProperties.java](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java). Nested configuration is declared in the linked class. Defaults are the field initializers. | Setting | Java type | Source default | | --- | --- | --- | +| `firefly.security.resource-server.strict-policy` | `boolean` | `false` | | `firefly.security.resource-server.permit-matchers` | `List` | `new ArrayList<>()` | | `firefly.security.resource-server.issuer` | `String` | `not initialized` | | `firefly.security.resource-server.audiences` | `List` | `new ArrayList<>()` | @@ -177,10 +189,10 @@ This module depends only on `security-webflux` (which transitively brings `secur `ResourceServerAutoConfiguration` contributes, each gated by `@ConditionalOnMissingBean`: - A verifying JWT decoder. `NimbusReactiveJwtDecoder.withPublicKey(...)` built from the active RSA `SigningKey` resolved through `KeyManagementPort.activeSigningKey()`, pinned to `RS256`. If no RSA key resolves, the context fails to start (fail-closed) rather than booting an unprotected server. -- A validator chain. A `DelegatingOAuth2TokenValidator` always enforcing `JwtTimestampValidator` (exp/nbf with skew), plus `JwtIssuerValidator` when `issuer` is configured and a custom audience validator when `audiences` is non-empty. +- A validator chain. A `DelegatingOAuth2TokenValidator` always enforcing required expiration and timestamp validation through `AccessTokenValidators.timestamps()` (including nbf/skew validation), plus `JwtIssuerValidator` when `issuer` is configured and a custom audience validator when `audiences` is non-empty. - Claim-to-authority mapping. An `AuthorityMappingPort` (default `ConfigurableAuthorityMapper`) driven by configurable role/scope claim paths and authority prefix, normalizing provider-specific claims (Keycloak/Cognito/Entra) into a uniform authority/scope set. - A framework principal. `JwtToFireflyPrincipalConverter` replaces Spring's default `JwtAuthenticationToken` with a `FireflyAuthenticationToken` whose principal is a `SecurityPrincipal` (built by `PrincipalFactory.fromClaims(...)`), so `@AuthenticationPrincipal SecurityPrincipal` works with its default configuration. -- A default-deny filter chain. `fireflySecurityWebFilterChain` disables CSRF, HTTP-Basic, form-login, and logout; permits only the explicitly configured `permitMatchers`; and routes `anyExchange()` through a `PolicyAuthorizationManager` backed by `PolicyDecisionPort`. Unconfigured routes are denied. +- An authenticated policy filter chain. `fireflySecurityWebFilterChain` disables CSRF, HTTP-Basic, form-login, and logout; permits only the explicitly configured `permitMatchers`; and routes `anyExchange()` through a `PolicyAuthorizationManager` backed by `PolicyDecisionPort`. Anonymous requests to unconfigured routes are denied. Authenticated authorization depends on the configured policy mode; strict mode requires an explicit permit. - Hardened response headers on every response: `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, a strict `Content-Security-Policy` (`default-src 'none'; frame-ancestors 'none'`), and HSTS (`includeSubDomains`, 365-day max-age). - Framework defaults for the remaining ports. `EmbeddedPolicyDecisionAdapter` (collecting any `PolicyRule` beans), `LoggingAuditEventAdapter`, and `ReactorSecurityContextAdapter`. all overridable. @@ -202,7 +214,8 @@ All keys live under `firefly.security.resource-server`: | Property | Default | Description | | --- | --- | --- | -| `permit-matchers` | _(empty)_ | Ant-style path patterns served without authentication. The only opt-out from default-deny. | +| `strict-policy` | `false` | Require an explicit permit; reject failed/empty rules and unsupported URL obligations. | +| `permit-matchers` | _(empty)_ | Ant-style path patterns served without authentication. | | `issuer` | _(none)_ | Expected `iss`; when set, a `JwtIssuerValidator` is enforced. | | `audiences` | _(empty)_ | Acceptable `aud` values; when non-empty, an audience validator is enforced. | | `authority-prefix` | `""` | Prefix applied to mapped authorities (e.g. `ROLE_`). | @@ -218,7 +231,7 @@ Bearer token → fireflySecurityWebFilterChain → NimbusReactiveJwtDecoder (RS256, key from KeyManagementPort) → DelegatingOAuth2TokenValidator (timestamp [+ issuer] [+ audience]) → JwtToFireflyPrincipalConverter → FireflyAuthenticationToken(SecurityPrincipal) - → authorizeExchange: permitMatchers → permitAll, else PolicyAuthorizationManager (PolicyDecisionPort, default-deny) + → authorizeExchange: permitMatchers → permitAll, else PolicyAuthorizationManager (PolicyDecisionPort, explicit strict mode or legacy RBAC deferral) → controller / handler ``` diff --git a/docs/site/modules/security-spi.md b/docs/site/modules/security-spi.md index d28ab2976..62f860caa 100644 --- a/docs/site/modules/security-spi.md +++ b/docs/site/modules/security-spi.md @@ -24,7 +24,7 @@ Connect the onboarding service's policy, relationship, revocation, key, or secre org.fireflyframework fireflyframework-security-spi - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/security-test.md b/docs/site/modules/security-test.md index 8d1544611..207c841f8 100644 --- a/docs/site/modules/security-test.md +++ b/docs/site/modules/security-test.md @@ -24,7 +24,7 @@ Create applicant/reviewer test principals and deterministic policy outcomes for org.fireflyframework fireflyframework-security-test - 26.10.21-SNAPSHOT + 26.10.22 test ``` @@ -192,7 +192,7 @@ If you are not inheriting the Firefly parent, pin the version explicitly: org.fireflyframework fireflyframework-security-test - 26.10.21-SNAPSHOT + 26.10.22 test ``` diff --git a/docs/site/modules/security-webflux.md b/docs/site/modules/security-webflux.md index b8502e342..16638dba3 100644 --- a/docs/site/modules/security-webflux.md +++ b/docs/site/modules/security-webflux.md @@ -24,7 +24,7 @@ Project the authenticated reviewer into the framework principal and connect URL org.fireflyframework fireflyframework-security-webflux - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/starter-application.md b/docs/site/modules/starter-application.md index 5aec520d9..11f611e71 100644 --- a/docs/site/modules/starter-application.md +++ b/docs/site/modules/starter-application.md @@ -24,7 +24,7 @@ An application-tier onboarding API can use these request/context conventions whi org.fireflyframework fireflyframework-starter-application - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/starter-core.md b/docs/site/modules/starter-core.md index 5b4aa43fc..d0ef270a3 100644 --- a/docs/site/modules/starter-core.md +++ b/docs/site/modules/starter-core.md @@ -26,7 +26,7 @@ A larger onboarding service may adopt this bundle for shared HTTP-client and ope org.fireflyframework fireflyframework-starter-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/starter-data.md b/docs/site/modules/starter-data.md index fec54dc7f..54d50bb58 100644 --- a/docs/site/modules/starter-data.md +++ b/docs/site/modules/starter-data.md @@ -24,7 +24,7 @@ Background document enrichment or verification jobs may need the data-job and pr org.fireflyframework fireflyframework-starter-data - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/starter-domain.md b/docs/site/modules/starter-domain.md index 43f084563..46859a638 100644 --- a/docs/site/modules/starter-domain.md +++ b/docs/site/modules/starter-domain.md @@ -24,7 +24,7 @@ Use this dependency bundle when application submission/review grows into a domai org.fireflyframework fireflyframework-starter-domain - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/utils.md b/docs/site/modules/utils.md index 3718d112a..5c2e011f6 100644 --- a/docs/site/modules/utils.md +++ b/docs/site/modules/utils.md @@ -24,7 +24,7 @@ After accepting an application, the service can render a confirmation template o org.fireflyframework fireflyframework-utils - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -32,6 +32,27 @@ After accepting an application, the service can render a confirmation template o The Java examples in this section are API fragments for application code. Use the stated imports and supply the application-owned collaborators described alongside them. +### Render a restricted published fragment + +Use `RestrictedHtmlTemplateRenderer` for the closed `RESTRICTED_XHTML_V1` profile. Its independent configuration accepts a deeply copied JSON-compatible model, exact BigInteger/BigDecimal and integral numbers; it rejects arbitrary Java/FreeMarker objects and floating-point values. Model strings remain data. Existing `TemplateRenderUtil` retains its application-owned classpath/file/PDF behavior. + +```java +import org.fireflyframework.utils.template.RestrictedHtmlTemplateRenderer; +import java.util.Map; + +var renderer = new RestrictedHtmlTemplateRenderer(); +var result = renderer.render("

${recipient}

", + Map.of("recipient", "Synthetic & Bank")); +if (!result.success()) { + throw new IllegalArgumentException(result.code()); +} +String fragment = result.html(); //

Synthetic & Bank

+``` + +The renderer forces HTML escaping, disables API/class construction and external template loaders, then validates a well-formed XHTML fragment with a closed print tag/attribute profile. Scripts, styles, links, images, custom/external entity declarations, DTDs and unknown markup reject the entire output. No partial document is returned. Successful output must contain visible text. Application-owned branding/shells and financial facts remain separate responsibilities; this profile does not render arbitrary CSS/assets or PDF. + +`RestrictedTemplateLimits.defaults()` bounds template256KiB, output1MiB, model5000nodes/depth32, strings65536UTF16 units,10000template elements and5000ms cooperative checks. Custom limits are immutable per instance. Codes are `UNSUPPORTED_TEMPLATE`, `UNSUPPORTED_MODEL`, `RENDER_LIMIT`, `EMPTY_OUTPUT`, `INVALID_HTML` and `RENDER_FAILED`; they do not disclose source/model fragments. Cooperative tracing cannot interrupt one expensive expression or establish a hard heap/RSS/CPU boundary. A service accepting published content must supply its separately qualified worker-process lifetime/heap controls when required. This utility alone does not provide that isolation. + ### Render a template to HTML Place `invoice.ftl` under `src/main/resources/templates/`: diff --git a/docs/site/modules/validators.md b/docs/site/modules/validators.md index 59448d004..5c7952dd8 100644 --- a/docs/site/modules/validators.md +++ b/docs/site/modules/validators.md @@ -24,7 +24,7 @@ The tutorial validates name and email with Jakarta constraints. Later onboarding org.fireflyframework fireflyframework-validators - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/web-idempotency.md b/docs/site/modules/web-idempotency.md new file mode 100644 index 000000000..1a55c43d9 --- /dev/null +++ b/docs/site/modules/web-idempotency.md @@ -0,0 +1,57 @@ +# HTTP idempotency and durable business effects + +HTTP response reuse is an optimization around an authorized request. The business service remains responsible for recording a durable operation key and applying its effects exactly once within its own transaction boundary. A cached response is not an operation ledger. + +## Configure the boundary + +1. Set `spring.application.name` or `firefly.web.idempotency.namespace`. Use an explicit service/environment namespace whenever deployments share a cache backend. +2. Authenticate and authorize the request before `IdempotencyWebFilter` (order `-80`). The default resolver requires an authenticated Spring `Authentication` containing a Jafly `SecurityPrincipal` with issuer and subject. It resolves tenant through the existing `TenantResolverPort`, or through the principal's nonblank `tenantId` when that port is absent. Unresolved trust bypasses response reuse. +3. For another authentication stack, implement `IdempotencyScopeResolver` as a bean. Return a stable namespace derived from verified issuer, caller, authorized tenant, and authorization state. Return `Mono.empty()` when trust cannot be resolved. Never derive authority from unvalidated request headers. +4. Select and qualify the backing cache using `firefly.cache.*`. The HTTP cache has its own manager/prefix. Namespace isolation is still required when multiple deployments use the same backend. +5. Keep the durable operation key inside the business command. Validate ownership and authorization on every request, including retries. If authorization exists only inside the handler, disable HTTP response reuse for that endpoint or move its policy check before this filter. + +## Request and response contract + +| Input or condition | Behavior | +| --- | --- | +| No retry header | Handler executes normally | +| `GET`, `HEAD`, `OPTIONS`, `TRACE` | Header is ignored; no mutation-cache read or write | +| Missing application namespace or unresolved trusted caller/tenant | Handler executes without response reuse | +| Multiple, blank, or longer-than-256-character keys on an eligible mutation | `400`, before handler execution | +| Same scoped key and byte-identical request fingerprint | Successful response is replayed | +| Same scoped key with changed body, content type, accepted representation/encoding/language, content encoding, or `If-Match`/`If-None-Match` | `409`, before another handler execution | +| Concurrent identical requests in one filter instance | One handler execution; other callers await its result | +| Concurrent conflicting input | Immediate `409`, without waiting for the original response | +| Successful empty response, including `204` | Empty response is reusable and waiting callers complete | +| Handled non-success response | Concurrent waiters receive it; later retries execute again | +| Handler failure/cancellation | Waiting callers terminate; local in-flight ownership is released | +| Oversized request body | `413`, before handler execution | +| Oversized response body | Buffering fails; no successful cache entry is recorded. The business effect may already exist and must be recovered through its durable key | + +The scoped key includes application namespace, trusted identity namespace, method, raw URI path, raw query, and the client key. Its `v2` prefix and SHA-256 digest avoid replaying legacy unscoped entries or storing raw identity/key material as cache keys. Default trusted identity includes issuer, subject, resolved tenant, and sorted current roles/scopes. + +The request fingerprint is SHA-256 over the exact request bytes plus `Content-Type`, `Accept`, `Content-Encoding`, `Accept-Encoding`, `Accept-Language`, `If-Match`, and `If-None-Match`. Semantically equivalent JSON with different bytes conflicts deliberately. Use a different retry key for changed input. Every ordered value of each selected header is included, including later field lines. Names, cardinalities, and values are framed with explicit UTF-8 byte lengths; combining two field lines into one comma-separated line may conservatively conflict even when HTTP semantics are equivalent. + +Only `2xx` responses are retained. Replays preserve status, body, content type, `Location`, `ETag`, `Content-Language`, `Content-Encoding`, `Content-Location`, `Content-Disposition`, `Last-Modified`, and `Vary`. Application-produced encoded bytes retain their complete `Content-Encoding` metadata; accepted encodings participate in the fingerprint. Cookies and authentication headers are not replayed. `Vary` is supported only when every named field belongs to the selected fingerprint headers. A response with `Vary: *` or an unsupported field is not retained; an already waiting duplicate receives `409` and must use durable business recovery. Later requests execute through the normal business handler. This includes CORS policies that add `Vary: Origin`, since Origin is outside this selected representation contract; do not assume that supplying a key makes retention active for such a route. Cache retention is configured in hours (24 by default); body limits default to 1 MiB for requests and 4 MiB for responses. This filter buffers a complete response and is unsuitable for streaming: opt out with `@DisableIdempotency` or disable the filter. + +## Failure, restart, and multiple processes + +Bounded response bytes are captured while writing, but status, content type, and safe replay headers are read only after successful delegate completion, including Spring `beforeCommit` callbacks. Retention decisions and waiting duplicates therefore use final committed metadata. A failed commit cannot complete a waiter with an earlier successful snapshot. + +Cache writes participate in the reactive request completion; they are not detached subscriptions. The local in-flight claim remains owned until cache completion, and a second lookup closes the completed-owner handoff race. A failed best-effort cache write can still leave the original business effect committed. Subsequent requests must recover from that effect's durable operation key. + +A second filter instance can replay an existing cache entry, including its request fingerprint. In-flight claims are held in memory and are not shared between processes. Two instances receiving the same key concurrently can both reach the business handler; a shared cache does not make those executions atomic. Crashes, cache outages, cache expiration, and a response limit failure require durable business reconciliation. Do not describe this feature as distributed exactly-once processing. + +Restart qualification of a business journey must verify its database operation claim, original effect, status history, and recovery response separately. The framework tests exercise scoped cache metadata and HTTP behavior, not bank ledger or provider transaction finality. + +## Qualification + +Run from the Jafly repository root with JDK 25: + +```bash +./mvnw -pl :fireflyframework-web -am verify +``` + +`ScopedIdempotencyWebFilterTest` covers route/method/query and issuer/subject/tenant/service isolation, changed-input rejection, concurrent same-input execution, body preservation, empty responses, failure/cancellation cleanup, delayed cache persistence, and bounded buffers. `IdempotencyHttpIntegrationTest` runs actual Reactor Netty HTTP against Spring WebFlux controller methods on a private loopback port. It verifies body delivery, cross-route and read/mutation separation, payload conflict, actual `@DisableIdempotency` behavior, raw-wire gzip decoding on original/replay, safe metadata and credential/cookie exclusions, complete multi-value header conflicts, field boundaries, unsupported Vary behavior, and final status/encoding/Vary from `beforeCommit` callbacks. Deterministic concurrent HTTP fixtures verify pending duplicates and failed commits without production test hooks. `IdempotencyAutoConfigurationTest` exercises real Caffeine cache wiring, application namespace fallback, and a custom trusted resolver. + +These are local framework qualification cases. Consumer adoption requires building the exact framework source artifact, qualifying each service's authentication/tenant/authorization configuration, and repeating its real experience-to-core journey tests. A framework build alone does not establish that deployed consumers use it. diff --git a/docs/site/modules/web.md b/docs/site/modules/web.md index 2aa68ee47..f359a6c06 100644 --- a/docs/site/modules/web.md +++ b/docs/site/modules/web.md @@ -24,7 +24,7 @@ The onboarding HTTP boundary needs consistent error responses and carefully chos org.fireflyframework fireflyframework-web - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -88,19 +88,35 @@ A typical error response (RFC 7807 Problem Details): ### Idempotent requests -Clients send a unique `X-Idempotency-Key` header on a write request; retries with the same key replay the original cached response instead of re-executing the handler. Opt an endpoint out with `@DisableIdempotency`: +The optional `X-Idempotency-Key` header enables bounded HTTP response reuse for `POST`, `PUT`, `PATCH`, and `DELETE`. `GET`, `HEAD`, `OPTIONS`, and `TRACE` always pass through. The [HTTP idempotency guide](web-idempotency.md) describes setup, scope, recovery, and the executable qualification cases. + +Configure a stable application/environment namespace and authenticated tenant context before relying on response reuse: + +```yaml +spring: + application: + name: payment-service +firefly: + web: + idempotency: + namespace: payment-service:sandbox + max-request-body-bytes: 1048576 + max-response-body-bytes: 4194304 +``` + +The default resolver uses an authenticated Jafly `SecurityPrincipal` and the existing `TenantResolverPort` when available. Without that port, it requires `SecurityPrincipal.tenantId()`. Missing trusted identity, tenant, or application namespace bypasses HTTP response reuse. A caller-supplied tenant header never establishes identity. Services using a different authentication model can provide an `IdempotencyScopeResolver` bean derived from their verified server-side context. + +A key is scoped by service/environment namespace, issuer, subject, tenant, current roles/scopes, HTTP method, raw path, and raw query. A different payload or relevant representation/precondition header under that scope returns `409` before another handler execution. Byte-identical input replays a successful status/body and selected representation headers, including `Location`. JSON whitespace and field order count as input changes. + +Enforce authorization before the filter. HTTP response reuse cannot replace resource authorization, durable business idempotency, transaction isolation, or distributed coordination. The cache can expire or be unavailable, and in-flight coordination belongs to one filter instance. Financial effects must retain their authoritative durable business key even when a cache is enabled. + +Opt streaming or other unsuitable controller methods out with `@DisableIdempotency`; auto-configuration resolves actual controller metadata before response reuse. Manually constructed filters must supply handler mappings to honor that annotation. Disable the entire filter with `firefly.web.idempotency.enabled=false` when endpoint-specific metadata is unavailable. ```java import org.fireflyframework.web.idempotency.annotation.DisableIdempotency; -@PostMapping("/payments") -public Mono pay(@RequestBody PaymentRequest req) { - // Send header "X-Idempotency-Key: "; a retry returns the same response. - return paymentService.charge(req); -} - @PostMapping("/heartbeat") -@DisableIdempotency // never deduplicated, header hidden from OpenAPI +@DisableIdempotency public Mono heartbeat() { return monitor.ping(); } @@ -133,6 +149,8 @@ The `firefly.web.cors` namespace is bound by [CorsProperties.java](https://githu The `firefly.web.error-handling` namespace is bound by [ErrorHandlingProperties.java](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/web/src/main/java/org/fireflyframework/web/error/config/ErrorHandlingProperties.java). Nested configuration is declared in the linked class. Defaults are the field initializers. +Source coordinates and exception type attached by the metadata aspect follow `include-debug-info`; cause type/message follow `include-exception-cause`. Both are omitted by default and suppressed under the `prod` or `production` profile even when the flags are enabled. This applies to business, converted HTTP-status, and validation metadata. The response filter copies metadata, preserving the exception's internal diagnostic record and ordinary business fields such as an operation ID or retry delay. These controls cover the framework's known diagnostic metadata keys; application-provided messages and custom metadata still need an appropriate disclosure policy. + | Setting | Java type | Source default | | --- | --- | --- | | `firefly.web.error-handling.include-stack-trace` | `boolean` | `false` | @@ -156,6 +174,9 @@ The `firefly.web.idempotency` namespace is bound by [IdempotencyProperties.java] | --- | --- | --- | | `firefly.web.idempotency.enabled` | `boolean` | `true` | | `firefly.web.idempotency.header-name` | `String` | `"X-Idempotency-Key"` | +| `firefly.web.idempotency.namespace` | `String` | unset; auto-configuration uses `spring.application.name` | +| `firefly.web.idempotency.max-request-body-bytes` | `int` | `1048576` | +| `firefly.web.idempotency.max-response-body-bytes` | `int` | `4194304` | | `firefly.web.idempotency.cache` | `Cache` | `new Cache()` | The `firefly.web.http-request-logging` namespace is bound by [HttpRequestLoggingProperties.java](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/web/src/main/java/org/fireflyframework/web/logging/config/HttpRequestLoggingProperties.java). Nested configuration is declared in the linked class. Defaults are the field initializers. @@ -309,6 +330,9 @@ firefly: idempotency: enabled: true # matchIfMissing=true (on by default) header-name: X-Idempotency-Key + namespace: payment-service:sandbox # unique service/environment sharing a cache backend + max-request-body-bytes: 1048576 + max-response-body-bytes: 4194304 cache: ttl-hours: 24 # TTL for cached idempotent responses diff --git a/docs/site/modules/webhooks-core.md b/docs/site/modules/webhooks-core.md index 421d82cc8..a9ec48aeb 100644 --- a/docs/site/modules/webhooks-core.md +++ b/docs/site/modules/webhooks-core.md @@ -24,7 +24,7 @@ Validate/enrich the inbound verification-provider request and publish its config org.fireflyframework fireflyframework-webhooks-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/webhooks-interfaces.md b/docs/site/modules/webhooks-interfaces.md index e4025d5c5..11daa02e4 100644 --- a/docs/site/modules/webhooks-interfaces.md +++ b/docs/site/modules/webhooks-interfaces.md @@ -24,7 +24,7 @@ Carry the provider name, JSON payload, event ID, and request metadata from verif org.fireflyframework fireflyframework-webhooks-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/webhooks-processor.md b/docs/site/modules/webhooks-processor.md index db07da6d7..51875f3ae 100644 --- a/docs/site/modules/webhooks-processor.md +++ b/docs/site/modules/webhooks-processor.md @@ -34,7 +34,7 @@ Implement the worker that turns a verified provider event into an application-st org.fireflyframework fireflyframework-webhooks-processor - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/webhooks-sdk.md b/docs/site/modules/webhooks-sdk.md index 3cde40628..41dda4b37 100644 --- a/docs/site/modules/webhooks-sdk.md +++ b/docs/site/modules/webhooks-sdk.md @@ -24,7 +24,7 @@ An integration client can use the generated ingestion contract when sending prov org.fireflyframework fireflyframework-webhooks-sdk - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/webhooks-web.md b/docs/site/modules/webhooks-web.md index a5cf1947d..80b5896ba 100644 --- a/docs/site/modules/webhooks-web.md +++ b/docs/site/modules/webhooks-web.md @@ -24,7 +24,7 @@ Expose the verification-provider ingestion endpoint through this service after i org.fireflyframework fireflyframework-webhooks-web - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/modules/webhooks.md b/docs/site/modules/webhooks.md index 642a92d61..8502d5163 100644 --- a/docs/site/modules/webhooks.md +++ b/docs/site/modules/webhooks.md @@ -51,7 +51,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-webhooks-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/docs/site/releases.md b/docs/site/releases.md index 61daac0cb..6179d0156 100644 --- a/docs/site/releases.md +++ b/docs/site/releases.md @@ -2,7 +2,7 @@ ![Unified Jafly releases](assets/banners/releases.svg) -The parent, BOM, and libraries share one version train. The working version is `26.10.21-SNAPSHOT`. See GitHub Releases and the registries for published artifacts. +The parent, BOM, and libraries share one version train. The working source version is `26.10.22`; publication is a separate release-workflow operation. See GitHub Releases and the registries for published artifacts. ## Workflows diff --git a/docs/site/versioning.md b/docs/site/versioning.md index 84e8cbdd0..9b6bc1060 100644 --- a/docs/site/versioning.md +++ b/docs/site/versioning.md @@ -2,13 +2,13 @@ ![One version train](assets/banners/versioning.svg) -Jafly uses padded CalVer versions: `YY.MM.PATCH` for a final release and `YY.MM.PATCH-SNAPSHOT` during development. The final component counts releases within the month; it does not represent a date. The current development version is `26.10.21-SNAPSHOT`. +Jafly uses padded CalVer versions: `YY.MM.PATCH` for a final release and `YY.MM.PATCH-SNAPSHOT` during development. The final component counts releases within the month; it does not represent a date. The current development version is `26.10.22`. ## What the version identifies A framework train covers the consumer parent, independent BOM, libraries, adapters, and component aggregators. Nested projects may inherit their effective version from a local framework parent. Internal framework dependencies select that same train; third-party libraries retain their own managed versions. -An application's version remains separate. An application at `1.0.0` can use framework train `26.10.21-SNAPSHOT`; the public parent/BOM use `fireflyframework.version` rather than the consumer's `${project.version}` for internal dependency management. +An application's version remains separate. An application at `1.0.0` can use framework train `26.10.22`; the public parent/BOM use `fireflyframework.version` rather than the consumer's `${project.version}` for internal dependency management. ## Development snapshots @@ -23,7 +23,7 @@ The manual snapshot workflow publishes to GitHub Packages only after verificatio ## Change the train ```sh -python3 scripts/set_version.py 26.10.21-SNAPSHOT +python3 scripts/set_version.py 26.10.22 python3 scripts/release_tools.py check python3 -m unittest discover -s tests -v python3 -m unittest discover -s tests/release -v diff --git a/mkdocs.yml b/mkdocs.yml index 5e5feaf46..fbf4f7e7d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -187,6 +187,7 @@ nav: - "Service operations": - "Observability": modules/observability.md - "Web": modules/web.md + - "HTTP idempotency": modules/web-idempotency.md - "Config server": modules/config-server.md - "Team-wide architecture": - "Build parent": modules/parent.md diff --git a/modules/agentic-bridge/README.md b/modules/agentic-bridge/README.md index 30ef4a103..9db9784e8 100644 --- a/modules/agentic-bridge/README.md +++ b/modules/agentic-bridge/README.md @@ -31,7 +31,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-agentic-bridge-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/agentic-bridge/docs/QUICK_START.md b/modules/agentic-bridge/docs/QUICK_START.md index 8027bd875..3a114cd16 100644 --- a/modules/agentic-bridge/docs/QUICK_START.md +++ b/modules/agentic-bridge/docs/QUICK_START.md @@ -13,7 +13,7 @@ Spring Boot service in five minutes. org.fireflyframework fireflyframework-agentic-bridge-starter - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/agentic-bridge/fireflyframework-agentic-bridge-autoconfigure/README.md b/modules/agentic-bridge/fireflyframework-agentic-bridge-autoconfigure/README.md index 311f47b96..2aa39c0c0 100644 --- a/modules/agentic-bridge/fireflyframework-agentic-bridge-autoconfigure/README.md +++ b/modules/agentic-bridge/fireflyframework-agentic-bridge-autoconfigure/README.md @@ -20,7 +20,7 @@ Configure the primary agent endpoint and authentication for the reviewer-assista org.fireflyframework fireflyframework-agentic-bridge-autoconfigure - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/agentic-bridge/fireflyframework-agentic-bridge-autoconfigure/pom.xml b/modules/agentic-bridge/fireflyframework-agentic-bridge-autoconfigure/pom.xml index 6978b9572..cd1398213 100644 --- a/modules/agentic-bridge/fireflyframework-agentic-bridge-autoconfigure/pom.xml +++ b/modules/agentic-bridge/fireflyframework-agentic-bridge-autoconfigure/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-agentic-bridge - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/agentic-bridge/fireflyframework-agentic-bridge-core/README.md b/modules/agentic-bridge/fireflyframework-agentic-bridge-core/README.md index a60613b71..2a5e64b7d 100644 --- a/modules/agentic-bridge/fireflyframework-agentic-bridge-core/README.md +++ b/modules/agentic-bridge/fireflyframework-agentic-bridge-core/README.md @@ -20,7 +20,7 @@ Call a remote agent from the onboarding service when a configured assistant is n org.fireflyframework fireflyframework-agentic-bridge-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/agentic-bridge/fireflyframework-agentic-bridge-core/pom.xml b/modules/agentic-bridge/fireflyframework-agentic-bridge-core/pom.xml index 7172a0e05..0ac923662 100644 --- a/modules/agentic-bridge/fireflyframework-agentic-bridge-core/pom.xml +++ b/modules/agentic-bridge/fireflyframework-agentic-bridge-core/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-agentic-bridge - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/agentic-bridge/fireflyframework-agentic-bridge-samples/pom.xml b/modules/agentic-bridge/fireflyframework-agentic-bridge-samples/pom.xml index c7b2a0cf8..e19ded244 100644 --- a/modules/agentic-bridge/fireflyframework-agentic-bridge-samples/pom.xml +++ b/modules/agentic-bridge/fireflyframework-agentic-bridge-samples/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-agentic-bridge - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/agentic-bridge/fireflyframework-agentic-bridge-starter/README.md b/modules/agentic-bridge/fireflyframework-agentic-bridge-starter/README.md index 0d902bc4e..e9fac7547 100644 --- a/modules/agentic-bridge/fireflyframework-agentic-bridge-starter/README.md +++ b/modules/agentic-bridge/fireflyframework-agentic-bridge-starter/README.md @@ -20,7 +20,7 @@ Add this bundle if the onboarding service needs Spring-managed agent client/brid org.fireflyframework fireflyframework-agentic-bridge-starter - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/agentic-bridge/fireflyframework-agentic-bridge-starter/pom.xml b/modules/agentic-bridge/fireflyframework-agentic-bridge-starter/pom.xml index 279cea7e6..9157c23c1 100644 --- a/modules/agentic-bridge/fireflyframework-agentic-bridge-starter/pom.xml +++ b/modules/agentic-bridge/fireflyframework-agentic-bridge-starter/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-agentic-bridge - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/agentic-bridge/pom.xml b/modules/agentic-bridge/pom.xml index 2cd1c3eb2..49a2c930f 100644 --- a/modules/agentic-bridge/pom.xml +++ b/modules/agentic-bridge/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-agentic-bridge - 26.10.21-SNAPSHOT + 26.10.22 pom Firefly Framework - Agentic Bridge diff --git a/modules/backoffice/README.md b/modules/backoffice/README.md index a994f56d7..1dfeee15f 100644 --- a/modules/backoffice/README.md +++ b/modules/backoffice/README.md @@ -20,7 +20,7 @@ A reviewer portal can use backoffice contexts and controller bases to expose int org.fireflyframework fireflyframework-backoffice - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/backoffice/pom.xml b/modules/backoffice/pom.xml index fca732422..d95af1a99 100644 --- a/modules/backoffice/pom.xml +++ b/modules/backoffice/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-backoffice - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Common Backoffice Library diff --git a/modules/batch/README.md b/modules/batch/README.md new file mode 100644 index 000000000..3ff6091de --- /dev/null +++ b/modules/batch/README.md @@ -0,0 +1,127 @@ +# Native Durable Batch + +Optional Spring Batch JDBC infrastructure for application-owned restartable jobs. This module uses the framework's Boot-managed Spring Batch 6.0.5, a PostgreSQL JobRepository, explicit JDBC transactions, and a bounded application-owned executor. + +Module qualification has passed 56 tests, including actual PostgreSQL rollback/checkpoints and separate packaged JVM stop, hard-kill/recovery/restart, and concurrent launch. Whole-framework compatibility and independent review remain pending. No published availability or product adoption is claimed. + +## Add the module explicitly + +```xml + + org.fireflyframework + fireflyframework-batch + 26.10.22 + +``` + +The framework parent/BOM manage this optional coordinate. Existing starters do not include it. The module does not bring Boot's Batch or JDBC starters, create a DataSource, start a scheduler, launch a job, or apply database DDL. An application supplies its PostgreSQL JDBC driver and the normal resources below. + +## Select three resources and disable startup jobs + +```yaml +spring: + batch: + job: + enabled: false +firefly: + batch: + durable: + enabled: true + data-source-ref: batchMetadataDataSource + transaction-manager-ref: batchMetadataTransactions + task-executor-ref: batchWorkers +``` + +The feature defaults to disabled. Enabled mode requires all three exact bean names and explicit `spring.batch.job.enabled=false`. A conventional name, `@Primary`, another available database, or an R2DBC connection factory does not select a fallback. + +The transaction manager must be a `DataSourceTransactionManager` or its `JdbcTransactionManager` subtype whose DataSource is the same selected object. The executor must be an initialized `ThreadPoolTaskExecutor` with equal core/max sizes from 1 to 32, queue capacity zero, and `ThreadPoolExecutor.AbortPolicy`. The application owns its lifecycle. Resource validation happens before a metadata connection is opened. + +This application configuration fragment uses an application-provided PostgreSQL driver and environment-bound connection properties: + +```java +import java.util.concurrent.ThreadPoolExecutor; +import javax.sql.DataSource; +import org.postgresql.ds.PGSimpleDataSource; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.jdbc.support.JdbcTransactionManager; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration(proxyBeanMethods = false) +class BatchResources { + @Bean + DataSource batchMetadataDataSource(Environment environment) { + var source = new PGSimpleDataSource(); + source.setURL(environment.getRequiredProperty("application.batch.jdbc-url")); + source.setUser(environment.getRequiredProperty("application.batch.username")); + source.setPassword(environment.getRequiredProperty("application.batch.password")); + return source; + } + + @Bean + JdbcTransactionManager batchMetadataTransactions( + @Qualifier("batchMetadataDataSource") DataSource source) { + return new JdbcTransactionManager(source); + } + + @Bean + ThreadPoolTaskExecutor batchWorkers() { + var executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(0); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.setThreadNamePrefix("application-batch-"); + return executor; + } +} +``` + +Use the host application's normal secret configuration. JDBC work is blocking: launch and operate it from appropriate worker threads, without blocking a reactive HTTP event loop. + +## Provision the exact native schema + +The resource `org/fireflyframework/batch/jdbc/schema-postgresql-6.0.5.sql` is an unchanged copy of the [Spring Batch 6.0.5 PostgreSQL schema](https://github.com/spring-projects/spring-batch/blob/v6.0.5/spring-batch-core/src/main/resources/org/springframework/batch/core/schema-postgresql.sql). Its SHA-256 is `f2ac75cae966e4eb1cd97ee6b11f130495b7109153754a7983befb87614d6e38`. + +Have the deployment owner apply it explicitly through the application's controlled migration process. It is outside Flyway's default migration location and has no automatic initializer. Select the intended schema through the DataSource. The initial supported table prefix is `BATCH_`; the three sequence names are `BATCH_JOB_INSTANCE_SEQ`, `BATCH_JOB_EXECUTION_SEQ`, and `BATCH_STEP_EXECUTION_SEQ`. + +The runtime role needs schema usage, native table read/insert/update access, and sequence usage/read access. It does not need schema ownership or CREATE permission. Readiness checks PostgreSQL identity, required columns/types, native sequences, and access without inserting a job or advancing a sequence. These checks do not certify storage hardware, backup policy, or every migration constraint. Native administrative deletion APIs additionally require deletion privileges if an owner elects to expose them. + +## Use native steps and native operator APIs + +`JdbcBatchSteps.chunk(name, chunkSize)` returns the native Batch 6 `ChunkOrientedStepBuilder`. `tasklet(name, tasklet)` returns its native tasklet builder. Both bind the explicitly selected JDBC transaction manager. Normal readers, processors, writers, streams, listeners, and job builders remain native Spring Batch APIs. + +A restartable reader must save a stable cursor in its `ExecutionContext` and reopen from that cursor. A committed checkpoint must refer to an immutable input snapshot, with a stable order and item identity. Re-querying today's changing account set under an old cursor does not establish a valid restart. + +A JDBC writer participates in the local transaction only when it uses the same selected DataSource through transaction-aware Spring JDBC access such as `JdbcTemplate`/`DataSourceUtils`. A different database, independent connection, R2DBC operation, or remote SDK call is outside that local transaction. A durable metadata repository alone does not establish atomic item writes: the native chunk builder defaults to a resource-free transaction manager unless explicitly configured. + +Use Batch 6 object-based `JobOperator.start(Job, JobParameters)`, `stop(JobExecution)`, `restart(JobExecution)`, and `recover(JobExecution)`, then re-read stored state through `JobRepository`. Keep the job registered under its stable name. A stop acknowledgment is a request; the current chunk may still commit. Wait for the stored stopped state and inspect its actual checkpoint before restart. A failed attempt and a new successful attempt remain separate executions of the same instance. + +There is no automatic recover/restart worker. Before native recovery after a hard failure, establish that the previous process cannot continue; for a distributed business effect, also establish the owner's lease/fence and producer safety. Native recovery changes metadata and does not fence an old remote request or prove that an external effect did not occur. + +## Project-owned providers + +Ordinary project repository/operator beans replace defaults without `@Primary` or special method names. A project `DefaultBatchConfiguration` or `@EnableBatchProcessing` configuration backs off the default provider group. Deferred Boot defaults preserve either registration order and actual project component scans. Enabled-mode resource/schema/readiness checks still run, require a single repository and operator, and reject known resource-free repositories. + +An opaque custom provider must use the selected JDBC resources and pass actual persistence/restart qualification. An interface or bean name cannot prove its storage behavior. Disabled mode leaves the application's existing Batch configuration alone. + +## Bind a business run outside the framework + +The application owns a durable run claim containing the exact tenant, book, job kind, cutoff, input snapshot, model/policy/currency revisions, and full intent digest. Exact retries must retain that claim; changed intent must conflict. Use identifying native parameters for stable tenant/run/intent references. Do not add a random run incrementer to turn a conflicting retry into a new accepted run. + +Do not persist bearer tokens, caller security contexts, credentials, personal facts, or large business payloads in job parameters or execution contexts. Store small versioned references and cursors. The native metadata schema has no built-in tenant authority: an exposed execution ID must first resolve to its owning run and current caller permission. The application must supply current service authority before each new business effect, including after restart. + +For an external producer, durably establish a stable item action before I/O, call the genuine owner SDK with current service credentials, and retain its authoritative receipt. Unknown outcomes stay unresolved until the producer supplies an observed effect or an authoritative no-effect closure under the same immutable intent/deadline. A metadata checkpoint or empty lookup is not such a receipt. Reconciliation exceptions need an owner, reason, retained evidence, and permitted human resolution; a batch retry must not invent a financial adjustment. + +This module supplies execution infrastructure. It does not implement bank/provider reconciliation, a balanced general ledger, IFRS 9 classification/measurement/ECL/hedge accounting, IFRS 17 measurement/reinsurance, statutory reporting, or a complete banking/insurance journey. + +## Qualification + +Module tests use disposable PostgreSQL and synthetic item rows. Configuration/classpath/resource guards, native schema access, project-provider backoff, transactional rollback, checkpoint persistence, process death/restart, concurrent launch, executor admission, and an owner-run/current-authority recipe are separate gates. The focused module gate currently passes 56 tests with zero failures, errors, or skips. Its owner-run recipe is test-owned application code using an explicit synthetic deployment principal and an actual strict in-process PolicyDecisionPort. It proves full-intent replay checks, current policy before new fixture effects, and owner OPEN state despite native COMPLETED when stored acknowledgments are missing. It does not qualify a real external financial producer or a deployed identity provider. Whole-framework and independent final review remain pending. + +Run the module through the framework's normal Maven verification with the configured dependency repository. PostgreSQL/container absence is a failed infrastructure gate, not a skipped durability test. Full framework compatibility and independent review are required before adoption. Publication planning currently fails closed for the new unmapped native coordinate until a reviewed package home/preflight is supplied; historical package associations remain unchanged. + + +The maintained executable fixtures are [checkpoint rollback/restart](src/test/java/org/fireflyframework/batch/jdbc/JdbcCheckpointDatabaseTest.java), [packaged process lifecycle/concurrent admission](src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java), and [application-owned run/current-policy recipe](src/test/java/org/fireflyframework/batch/jdbc/OwnedRunBoundaryDatabaseTest.java). The latter uses only a local in-process PDP inside its synthetic JDBC writer; an application must keep remote policy or producer network I/O outside held database locks and implement its own durable effect protocol. Do not copy the fixture's trusted principal construction into an HTTP authentication boundary. diff --git a/modules/batch/pom.xml b/modules/batch/pom.xml new file mode 100644 index 000000000..6f702ed7a --- /dev/null +++ b/modules/batch/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + org.fireflyframework + fireflyframework-parent + 26.10.22 + ../../build/parent/pom.xml + + fireflyframework-batch + Firefly Framework - Durable Batch + Opt-in native Spring Batch JDBC infrastructure + + + + org.springframework.batch + spring-batch-core + + + org.springframework + spring-jdbc + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-batch + test + + + org.springframework + spring-r2dbc + test + + + org.postgresql + postgresql + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.fireflyframework + fireflyframework-security-core + test + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + integration-testverify + + + + + ${project.build.directory}/${project.build.finalName}.jar + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.7.0 + + + runtime-test-classpath + generate-test-resources + build-classpath + + runtime + ${project.build.directory}/runtime-classpath.txt + + + + + + + diff --git a/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableBatchAutoConfiguration.java b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableBatchAutoConfiguration.java new file mode 100644 index 000000000..da4829730 --- /dev/null +++ b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableBatchAutoConfiguration.java @@ -0,0 +1,131 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.DependsOn; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; +import org.springframework.util.ClassUtils; + +/** Opt-in infrastructure for explicitly configured durable JDBC jobs. */ +@AutoConfiguration( + beforeName = { + "org.springframework.boot.batch.autoconfigure.BatchAutoConfiguration", + "org.springframework.boot.batch.autoconfigure.BatchJobLauncherAutoConfiguration" + }) +@ConditionalOnProperty(prefix = "firefly.batch.durable", name = "enabled", havingValue = "true") +@EnableConfigurationProperties(DurableBatchProperties.class) +@Import(DurableBatchAutoConfiguration.JdbcInfrastructure.class) +public class DurableBatchAutoConfiguration { + @Bean + static BeanFactoryPostProcessor durableBatchClasspathGuard() { + return beanFactory -> { + for (String required : + new String[] { + "org.springframework.batch.core.repository.JobRepository", + "org.springframework.batch.core.configuration.support.JdbcDefaultBatchConfiguration", + "org.springframework.jdbc.datasource.DataSourceTransactionManager", + "org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor" + }) { + if (!ClassUtils.isPresent(required, beanFactory.getBeanClassLoader())) { + throw new IllegalStateException("Durable Batch requires " + required); + } + } + }; + } + + @Bean + InitializingBean durableBatchSettingsGuard( + DurableBatchProperties properties, Environment environment) { + return () -> { + if (!Boolean.FALSE.equals( + environment.getProperty("spring.batch.job.enabled", Boolean.class))) { + throw new IllegalStateException( + "Durable Batch requires explicit spring.batch.job.enabled=false"); + } + requireReference(properties.getDataSourceRef(), "data-source-ref"); + requireReference(properties.getTransactionManagerRef(), "transaction-manager-ref"); + requireReference(properties.getTaskExecutorRef(), "task-executor-ref"); + }; + } + + @Bean + @DependsOn("durableBatchSettingsGuard") + JdbcBatchResources durableJdbcBatchResources( + DurableBatchProperties properties, BeanFactory beans) { + return JdbcBatchResources.resolve(properties, beans); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass( + name = + "org.springframework.batch.core.configuration.support.JdbcDefaultBatchConfiguration") + @Import(JdbcReadiness.class) + static class JdbcInfrastructure {} + + @Configuration(proxyBeanMethods = false) + static class JdbcReadiness { + @Bean + @org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean( + org.fireflyframework.batch.jdbc.JdbcBatchSteps.class) + org.fireflyframework.batch.jdbc.JdbcBatchSteps durableJdbcBatchSteps( + org.springframework.batch.core.repository.JobRepository repository, + JdbcBatchResources resources) { + return new org.fireflyframework.batch.jdbc.JdbcBatchSteps( + repository, resources.transactionManager()); + } + + @Bean + SmartInitializingSingleton durableBatchReadiness( + JdbcBatchResources resources, + ObjectProvider + repositories, + ObjectProvider operators) { + return () -> { + var selected = repositories.stream().toList(); + if (selected.size() != 1 || operators.stream().count() != 1) { + throw new IllegalStateException( + "Durable Batch requires exactly one JobRepository and JobOperator"); + } + if (selected.getFirst() + instanceof + org.springframework.batch.core.repository.support + .ResourcelessJobRepository) { + throw new IllegalStateException( + "Durable Batch cannot use a ResourcelessJobRepository"); + } + JdbcBatchSchemaValidator.validate(resources.dataSource()); + }; + } + } + + private static void requireReference(String value, String property) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("firefly.batch.durable." + property + " is required"); + } + } +} diff --git a/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableBatchProperties.java b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableBatchProperties.java new file mode 100644 index 000000000..3f5bfc5a3 --- /dev/null +++ b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableBatchProperties.java @@ -0,0 +1,59 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Names of application-owned resources; no database or executor is created implicitly. */ +@ConfigurationProperties("firefly.batch.durable") +public class DurableBatchProperties { + private boolean enabled; + private String dataSourceRef; + private String transactionManagerRef; + private String taskExecutorRef; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDataSourceRef() { + return dataSourceRef; + } + + public void setDataSourceRef(String dataSourceRef) { + this.dataSourceRef = dataSourceRef; + } + + public String getTransactionManagerRef() { + return transactionManagerRef; + } + + public void setTransactionManagerRef(String transactionManagerRef) { + this.transactionManagerRef = transactionManagerRef; + } + + public String getTaskExecutorRef() { + return taskExecutorRef; + } + + public void setTaskExecutorRef(String taskExecutorRef) { + this.taskExecutorRef = taskExecutorRef; + } +} diff --git a/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableJdbcBatchAutoConfiguration.java b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableJdbcBatchAutoConfiguration.java new file mode 100644 index 000000000..842923eda --- /dev/null +++ b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/DurableJdbcBatchAutoConfiguration.java @@ -0,0 +1,47 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +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.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** Default providers are deferred until ordinary project configurations have been registered. */ +@AutoConfiguration( + after = DurableBatchAutoConfiguration.class, + beforeName = { + "org.springframework.boot.batch.autoconfigure.BatchAutoConfiguration", + "org.springframework.boot.batch.autoconfigure.BatchJobLauncherAutoConfiguration" + }) +@ConditionalOnClass( + name = "org.springframework.batch.core.configuration.support.JdbcDefaultBatchConfiguration") +@ConditionalOnProperty(prefix = "firefly.batch.durable", name = "enabled", havingValue = "true") +@Import(DurableJdbcBatchAutoConfiguration.DefaultProviders.class) +public class DurableJdbcBatchAutoConfiguration { + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean( + value = + org.springframework.batch.core.configuration.support.DefaultBatchConfiguration + .class, + annotation = + org.springframework.batch.core.configuration.annotation.EnableBatchProcessing + .class) + @Import(JdbcBatchConfiguration.class) + static class DefaultProviders {} +} diff --git a/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchConfiguration.java b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchConfiguration.java new file mode 100644 index 000000000..dce6f7417 --- /dev/null +++ b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchConfiguration.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import org.springframework.batch.core.configuration.support.JdbcDefaultBatchConfiguration; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; + +@Configuration(proxyBeanMethods = false) +class JdbcBatchConfiguration extends JdbcDefaultBatchConfiguration { + private final JdbcBatchResources resources; + + JdbcBatchConfiguration(JdbcBatchResources resources) { + this.resources = resources; + } + + @Bean + @Override + @ConditionalOnMissingBean(value = JobRepository.class, name = "jobRepository") + public JobRepository jobRepository() { + JdbcBatchSchemaValidator.validate(resources.dataSource()); + return super.jobRepository(); + } + + @Bean + @Override + @ConditionalOnMissingBean(value = JobOperator.class, name = "jobOperator") + public JobOperator jobOperator(JobRepository repository) { + return super.jobOperator(repository); + } + + @Override + protected DataSource getDataSource() { + return resources.dataSource(); + } + + @Override + protected PlatformTransactionManager getTransactionManager() { + return resources.transactionManager(); + } + + @Override + protected TaskExecutor getTaskExecutor() { + return resources.taskExecutor(); + } +} diff --git a/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResources.java b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResources.java new file mode 100644 index 000000000..e4199894c --- /dev/null +++ b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResources.java @@ -0,0 +1,83 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +import javax.sql.DataSource; + +record JdbcBatchResources( + DataSource dataSource, + DataSourceTransactionManager transactionManager, + ThreadPoolTaskExecutor taskExecutor) { + static JdbcBatchResources resolve(DurableBatchProperties properties, BeanFactory beans) { + DataSource source = + selected(beans, properties.getDataSourceRef(), DataSource.class, "data-source-ref"); + DataSourceTransactionManager manager = + selected( + beans, + properties.getTransactionManagerRef(), + DataSourceTransactionManager.class, + "transaction-manager-ref"); + if (manager.getDataSource() != source) { + throw new IllegalStateException( + "Durable Batch manager must own the same DataSource selected by" + + " data-source-ref"); + } + ThreadPoolTaskExecutor executor = + selected( + beans, + properties.getTaskExecutorRef(), + ThreadPoolTaskExecutor.class, + "task-executor-ref"); + if (executor.getCorePoolSize() < 1 + || executor.getCorePoolSize() > 32 + || executor.getCorePoolSize() != executor.getMaxPoolSize()) { + throw new IllegalStateException( + "Durable Batch task-executor-ref requires core=max in 1..32"); + } + if (executor.getQueueCapacity() != 0) { + throw new IllegalStateException( + "Durable Batch task-executor-ref requires queueCapacity=0"); + } + if (!(executor.getThreadPoolExecutor().getRejectedExecutionHandler() + instanceof ThreadPoolExecutor.AbortPolicy)) { + throw new IllegalStateException("Durable Batch task-executor-ref requires AbortPolicy"); + } + return new JdbcBatchResources(source, manager, executor); + } + + private static T selected(BeanFactory beans, String name, Class type, String property) { + try { + return beans.getBean(name, type); + } catch (BeansException error) { + throw new IllegalStateException( + "firefly.batch.durable." + + property + + " must select a " + + type.getSimpleName() + + " bean named '" + + name + + "'", + error); + } + } +} diff --git a/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchSchemaValidator.java b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchSchemaValidator.java new file mode 100644 index 000000000..6b557c5fd --- /dev/null +++ b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchSchemaValidator.java @@ -0,0 +1,148 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Map; +import java.util.Set; + +import javax.sql.DataSource; + +final class JdbcBatchSchemaValidator { + private static final Map COLUMNS = + Map.of( + "BATCH_JOB_INSTANCE", "JOB_INSTANCE_ID,VERSION,JOB_NAME,JOB_KEY", + "BATCH_JOB_EXECUTION", + "JOB_EXECUTION_ID,VERSION,JOB_INSTANCE_ID,CREATE_TIME,START_TIME,END_TIME,STATUS,EXIT_CODE,EXIT_MESSAGE,LAST_UPDATED", + "BATCH_JOB_EXECUTION_PARAMS", + "JOB_EXECUTION_ID,PARAMETER_NAME,PARAMETER_TYPE,PARAMETER_VALUE,IDENTIFYING", + "BATCH_STEP_EXECUTION", + "STEP_EXECUTION_ID,VERSION,STEP_NAME,JOB_EXECUTION_ID,CREATE_TIME,START_TIME,END_TIME,STATUS,COMMIT_COUNT,READ_COUNT,FILTER_COUNT,WRITE_COUNT,READ_SKIP_COUNT,WRITE_SKIP_COUNT,PROCESS_SKIP_COUNT,ROLLBACK_COUNT,EXIT_CODE,EXIT_MESSAGE,LAST_UPDATED", + "BATCH_STEP_EXECUTION_CONTEXT", + "STEP_EXECUTION_ID,SHORT_CONTEXT,SERIALIZED_CONTEXT", + "BATCH_JOB_EXECUTION_CONTEXT", + "JOB_EXECUTION_ID,SHORT_CONTEXT,SERIALIZED_CONTEXT"); + private static final Set NUMBERS = + Set.of( + "JOB_INSTANCE_ID", + "VERSION", + "JOB_EXECUTION_ID", + "STEP_EXECUTION_ID", + "COMMIT_COUNT", + "READ_COUNT", + "FILTER_COUNT", + "WRITE_COUNT", + "READ_SKIP_COUNT", + "WRITE_SKIP_COUNT", + "PROCESS_SKIP_COUNT", + "ROLLBACK_COUNT"); + private static final Set TIMES = + Set.of("CREATE_TIME", "START_TIME", "END_TIME", "LAST_UPDATED"); + private static final String[] SEQUENCES = { + "BATCH_JOB_INSTANCE_SEQ", "BATCH_JOB_EXECUTION_SEQ", "BATCH_STEP_EXECUTION_SEQ" + }; + + private JdbcBatchSchemaValidator() {} + + static void validate(DataSource source) { + try (Connection connection = source.getConnection()) { + if (!"PostgreSQL".equals(connection.getMetaData().getDatabaseProductName())) { + throw new IllegalStateException( + "Durable Batch currently requires PostgreSQL metadata"); + } + boolean wasReadOnly = connection.isReadOnly(); + try { + connection.setReadOnly(true); + for (var table : COLUMNS.entrySet()) { + try (var statement = connection.createStatement(); + var result = + statement.executeQuery( + "SELECT " + + table.getValue() + + " FROM " + + table.getKey() + + " WHERE 1=0")) { + var metadata = result.getMetaData(); + String[] columns = table.getValue().split(","); + for (int index = 0; index < columns.length; index++) { + String column = columns[index]; + int expected = + NUMBERS.contains(column) + ? Types.BIGINT + : TIMES.contains(column) + ? Types.TIMESTAMP + : column.equals("IDENTIFYING") + ? Types.CHAR + : Types.VARCHAR; + if (metadata.getColumnType(index + 1) != expected) { + throw new IllegalStateException( + "Durable Batch has incompatible native column: " + + table.getKey() + + "." + + column); + } + } + } + try (var statement = + connection.prepareStatement( + "SELECT has_table_privilege(current_user,CAST(? AS" + + " regclass),'SELECT') AND" + + " has_table_privilege(current_user,CAST(? AS" + + " regclass),'INSERT') AND" + + " has_table_privilege(current_user,CAST(? AS" + + " regclass),'UPDATE')")) { + for (int index = 1; index <= 3; index++) { + statement.setString( + index, table.getKey().toLowerCase(java.util.Locale.ROOT)); + } + try (var result = statement.executeQuery()) { + if (!result.next() || !result.getBoolean(1)) { + throw new IllegalStateException( + "Durable Batch lacks native table access: " + + table.getKey()); + } + } + } + } + for (String sequence : SEQUENCES) { + try (var statement = + connection.prepareStatement( + "SELECT c.relkind='S' AND" + + " has_sequence_privilege(current_user,c.oid,'USAGE') AND" + + " has_sequence_privilege(current_user,c.oid,'SELECT')" + + " FROM pg_catalog.pg_class c WHERE c.oid=CAST(? AS" + + " regclass)")) { + statement.setString(1, sequence.toLowerCase(java.util.Locale.ROOT)); + try (var result = statement.executeQuery()) { + if (!result.next() || !result.getBoolean(1)) { + throw new IllegalStateException( + "Durable Batch lacks native sequence access: " + sequence); + } + } + } + } + } finally { + connection.setReadOnly(wasReadOnly); + } + } catch (SQLException error) { + throw new IllegalStateException( + "Durable Batch requires the provisioned Spring Batch 6.0.5 PostgreSQL schema", + error); + } + } +} diff --git a/modules/batch/src/main/java/org/fireflyframework/batch/jdbc/JdbcBatchSteps.java b/modules/batch/src/main/java/org/fireflyframework/batch/jdbc/JdbcBatchSteps.java new file mode 100644 index 000000000..aec731a8d --- /dev/null +++ b/modules/batch/src/main/java/org/fireflyframework/batch/jdbc/JdbcBatchSteps.java @@ -0,0 +1,49 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.jdbc; + +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.ChunkOrientedStepBuilder; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.builder.TaskletStepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.util.Assert; + +/** Native JDBC step-builder entry point. */ +public final class JdbcBatchSteps { + private final JobRepository repository; + private final DataSourceTransactionManager manager; + + public JdbcBatchSteps(JobRepository repository, DataSourceTransactionManager manager) { + Assert.notNull(repository, "JobRepository is required"); + Assert.notNull(manager, "JDBC transaction manager is required"); + this.repository = repository; + this.manager = manager; + } + + public ChunkOrientedStepBuilder chunk(String name, int size) { + Assert.hasText(name, "Step name is required"); + Assert.isTrue(size > 0, "Chunk size must be positive"); + return new StepBuilder(name, repository).chunk(size).transactionManager(manager); + } + + public TaskletStepBuilder tasklet(String name, Tasklet tasklet) { + Assert.hasText(name, "Step name is required"); + Assert.notNull(tasklet, "Tasklet is required"); + return new StepBuilder(name, repository).tasklet(tasklet, manager); + } +} diff --git a/modules/batch/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/batch/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..f07a9f3ba --- /dev/null +++ b/modules/batch/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.fireflyframework.batch.autoconfigure.DurableBatchAutoConfiguration +org.fireflyframework.batch.autoconfigure.DurableJdbcBatchAutoConfiguration diff --git a/modules/batch/src/main/resources/org/fireflyframework/batch/jdbc/schema-postgresql-6.0.5.sql b/modules/batch/src/main/resources/org/fireflyframework/batch/jdbc/schema-postgresql-6.0.5.sql new file mode 100644 index 000000000..097a9d6c1 --- /dev/null +++ b/modules/batch/src/main/resources/org/fireflyframework/batch/jdbc/schema-postgresql-6.0.5.sql @@ -0,0 +1,78 @@ +-- Autogenerated: do not edit this file + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID) + references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL, + PARAMETER_NAME VARCHAR(100) NOT NULL, + PARAMETER_TYPE VARCHAR(100) NOT NULL, + PARAMETER_VALUE VARCHAR(2500), + IDENTIFYING CHAR(1) NOT NULL, + constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + COMMIT_COUNT BIGINT, + READ_COUNT BIGINT, + FILTER_COUNT BIGINT, + WRITE_COUNT BIGINT, + READ_SKIP_COUNT BIGINT, + WRITE_SKIP_COUNT BIGINT, + PROCESS_SKIP_COUNT BIGINT, + ROLLBACK_COUNT BIGINT, + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID) + references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO CYCLE; diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/DurableBatchAutoConfigurationTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/DurableBatchAutoConfigurationTest.java new file mode 100644 index 000000000..b2c37e8ee --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/DurableBatchAutoConfigurationTest.java @@ -0,0 +1,115 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class DurableBatchAutoConfigurationTest { + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + DurableBatchAutoConfiguration.class, + DurableJdbcBatchAutoConfiguration.class)); + + @Test + void absentModeCreatesNoBatchInfrastructure() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context) + .doesNotHaveBean(JobRepository.class) + .doesNotHaveBean(JobOperator.class); + assertThat(context).doesNotHaveBean(javax.sql.DataSource.class); + }); + } + + @Test + void falseModeIgnoresIncompleteDurableSettings() { + runner.withPropertyValues( + "firefly.batch.durable.enabled=false", + "firefly.batch.durable.data-source-ref=not-present") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context) + .doesNotHaveBean(JobRepository.class) + .doesNotHaveBean(JobOperator.class); + }); + } + + @Test + void disabledModeToleratesMissingBatchClasses() { + runner.withClassLoader(new FilteredClassLoader("org.springframework.batch")) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void disabledModeToleratesMissingJdbcClasses() { + runner.withClassLoader(new FilteredClassLoader("org.springframework.jdbc")) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void explicitModeCannotSilentlyIgnoreMissingJdbcClasses() { + runner.withClassLoader(new FilteredClassLoader("org.springframework.jdbc")) + .withPropertyValues( + "firefly.batch.durable.enabled=true", "spring.batch.job.enabled=false") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void explicitModeCannotSilentlyIgnoreMissingBatchClasses() { + runner.withClassLoader(new FilteredClassLoader("org.springframework.batch")) + .withPropertyValues( + "firefly.batch.durable.enabled=true", "spring.batch.job.enabled=false") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void explicitModeRequiresDataSourceReference() { + runner.withPropertyValues( + "firefly.batch.durable.enabled=true", "spring.batch.job.enabled=false") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("data-source-ref"); + }); + } + + @Test + void explicitModeRequiresStartupRunnerToBeDisabled() { + runner.withPropertyValues( + "firefly.batch.durable.enabled=true", + "firefly.batch.durable.data-source-ref=ownedDataSource", + "firefly.batch.durable.transaction-manager-ref=ownedManager", + "firefly.batch.durable.task-executor-ref=ownedExecutor") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("spring.batch.job.enabled=false"); + }); + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/DurableBatchBootCoexistenceTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/DurableBatchBootCoexistenceTest.java new file mode 100644 index 000000000..ccea2c7ce --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/DurableBatchBootCoexistenceTest.java @@ -0,0 +1,248 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.fireflyframework.batch.fixture.PostgresBatchFixture; +import org.fireflyframework.batch.fixture.project.ScannedNativeProviders; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.configuration.support.JdbcDefaultBatchConfiguration; +import org.springframework.batch.core.configuration.support.MapJobRegistry; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.launch.support.JobOperatorFactoryBean; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.repository.support.JdbcJobRepositoryFactoryBean; +import org.springframework.batch.core.repository.support.ResourcelessJobRepository; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.batch.autoconfigure.BatchAutoConfiguration; +import org.springframework.boot.batch.autoconfigure.BatchJobLauncherAutoConfiguration; +import org.springframework.boot.batch.autoconfigure.JobLauncherApplicationRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.task.TaskExecutor; +import org.springframework.jdbc.support.JdbcTransactionManager; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; + +class DurableBatchBootCoexistenceTest extends PostgresBatchFixture { + private ApplicationContextRunner enabled(DataSource source) { + return new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + DurableBatchAutoConfiguration.class, + DurableJdbcBatchAutoConfiguration.class, + BatchAutoConfiguration.class, + BatchJobLauncherAutoConfiguration.class)) + .withPropertyValues( + "firefly.batch.durable.enabled=true", + "spring.batch.job.enabled=false", + "firefly.batch.durable.data-source-ref=ownedDataSource", + "firefly.batch.durable.transaction-manager-ref=ownedManager", + "firefly.batch.durable.task-executor-ref=ownedExecutor") + .withBean("ownedDataSource", DataSource.class, () -> source) + .withBean( + "ownedManager", + JdbcTransactionManager.class, + () -> new JdbcTransactionManager(source)) + .withBean( + "ownedExecutor", + ThreadPoolTaskExecutor.class, + PostgresBatchFixture::executor); + } + + @Test + void realBootConfigurationCannotCreateResourceFreeRepositoryOrStartupRunner() throws Exception { + enabled(schema("complete")) + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(JobRepository.class) + .hasSingleBean(JobOperator.class); + assertThat(context.getBean(JobRepository.class)) + .isNotInstanceOf(ResourcelessJobRepository.class); + assertThat(context).doesNotHaveBean(JobLauncherApplicationRunner.class); + }); + } + + @Test + void ordinaryProjectBeansReplaceNativeDefaultsWithoutPrimary() throws Exception { + assertPlainProject(PlainProject.class); + } + + @Test + void plainProjectImportFirstStillReplacesDefaults() throws Exception { + assertPlainProject(ProjectFirst.class); + } + + @Test + void plainProjectImportLastStillReplacesDefaults() throws Exception { + assertPlainProject(ProjectLast.class); + } + + @Test + void actualProjectComponentScanReplacesProvidersWithoutPrimary() throws Exception { + assertPlainProject(ScannedProject.class); + } + + private void assertPlainProject(Class project) throws Exception { + DataSource source = schema("complete"); + enabled(source) + .withUserConfiguration(project) + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(JobRepository.class) + .hasSingleBean(JobOperator.class); + assertThat(context.getBean(JobRepository.class)) + .isSameAs(context.getBean("projectRepository")); + assertThat(context.getBean(JobOperator.class)) + .isSameAs(context.getBean("projectOperator")); + context.getBean(JobRepository.class) + .createJobInstance( + "project-owned-job", + new JobParametersBuilder() + .addString("tenant", "owned", true) + .toJobParameters()); + }); + assertThat( + new org.springframework.jdbc.core.JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isEqualTo(1); + } + + @Test + void projectJdbcConfigurationBacksOffDefaultGroup() throws Exception { + enabled(schema("complete")) + .withUserConfiguration(ProjectJdbc.class) + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(JobRepository.class) + .hasSingleBean(JobOperator.class); + assertThat(context.getBeansOfType(JdbcDefaultBatchConfiguration.class)) + .hasSize(1); + assertThat(context.getBean(JdbcDefaultBatchConfiguration.class)) + .isInstanceOf(ProjectJdbc.class); + context.getBean(JobRepository.class) + .createJobInstance( + "project-config-job", + new JobParametersBuilder().toJobParameters()); + }); + } + + @Test + void enabledProjectResourceFreeRepositoryIsRejected() throws Exception { + enabled(schema("complete")) + .withUserConfiguration(ResourceFreeProject.class) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void disabledModuleLeavesProjectResourceFreeLegacyConfigurationAlone() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + DurableBatchAutoConfiguration.class, + DurableJdbcBatchAutoConfiguration.class)) + .withUserConfiguration(ResourceFreeProject.class) + .run( + context -> { + assertThat(context).hasNotFailed().hasSingleBean(JobRepository.class); + assertThat(context.getBean(JobRepository.class)) + .isInstanceOf(ResourcelessJobRepository.class); + }); + } + + @Configuration(proxyBeanMethods = false) + @ComponentScan(basePackageClasses = ScannedNativeProviders.class) + static class ScannedProject {} + + @Configuration(proxyBeanMethods = false) + static class PlainProject { + @Bean + JobRepository projectRepository(JdbcBatchResources resources) throws Exception { + var factory = new JdbcJobRepositoryFactoryBean(); + factory.setDataSource(resources.dataSource()); + factory.setTransactionManager(resources.transactionManager()); + factory.setDatabaseType("POSTGRES"); + factory.afterPropertiesSet(); + return factory.getObject(); + } + + @Bean + JobOperator projectOperator(JobRepository repository, JdbcBatchResources resources) + throws Exception { + var factory = new JobOperatorFactoryBean(); + factory.setJobRepository(repository); + factory.setJobRegistry(new MapJobRegistry()); + factory.setTransactionManager(resources.transactionManager()); + factory.setTaskExecutor(resources.taskExecutor()); + factory.afterPropertiesSet(); + return factory.getObject(); + } + } + + @Configuration(proxyBeanMethods = false) + @Import({PlainProject.class, DurableBatchAutoConfiguration.class}) + static class ProjectFirst {} + + @Configuration(proxyBeanMethods = false) + @Import({DurableBatchAutoConfiguration.class, PlainProject.class}) + static class ProjectLast {} + + @Configuration(proxyBeanMethods = false) + static class ProjectJdbc extends JdbcDefaultBatchConfiguration { + private final JdbcBatchResources resources; + + ProjectJdbc(JdbcBatchResources resources) { + this.resources = resources; + } + + @Override + protected DataSource getDataSource() { + return resources.dataSource(); + } + + @Override + protected PlatformTransactionManager getTransactionManager() { + return resources.transactionManager(); + } + + @Override + protected TaskExecutor getTaskExecutor() { + return resources.taskExecutor(); + } + } + + @Configuration(proxyBeanMethods = false) + static class ResourceFreeProject { + @Bean + JobRepository projectRepository() { + return new ResourcelessJobRepository(); + } + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResourceSelectionTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResourceSelectionTest.java new file mode 100644 index 000000000..af3324dab --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResourceSelectionTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import io.r2dbc.spi.ConnectionFactory; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.jdbc.datasource.AbstractDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.sql.Connection; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.sql.DataSource; + +class JdbcBatchResourceSelectionTest { + private final AtomicInteger connections = new AtomicInteger(); + private final DataSource dataSource = + new AbstractDataSource() { + @Override + public Connection getConnection() { + connections.incrementAndGet(); + throw new AssertionError("Invalid resources must fail before JDBC acquisition"); + } + + @Override + public Connection getConnection(String username, String password) { + return getConnection(); + } + }; + + private ApplicationContextRunner enabled() { + return new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + DurableBatchAutoConfiguration.class, + DurableJdbcBatchAutoConfiguration.class)) + .withPropertyValues( + "firefly.batch.durable.enabled=true", + "spring.batch.job.enabled=false", + "firefly.batch.durable.data-source-ref=ownedDataSource", + "firefly.batch.durable.transaction-manager-ref=ownedManager", + "firefly.batch.durable.task-executor-ref=ownedExecutor"); + } + + private ThreadPoolTaskExecutor executor() { + var executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(0); + return executor; + } + + private ApplicationContextRunner resources(Object source, Object manager, Object executor) { + return enabled() + .withBean("ownedDataSource", Object.class, () -> source) + .withBean("ownedManager", Object.class, () -> manager) + .withBean("ownedExecutor", Object.class, () -> executor); + } + + private void rejects(ApplicationContextRunner runner, String reason) { + runner.run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasStackTraceContaining(reason); + assertThat(connections).hasValue(0); + }); + } + + @Test + void namedResourcesMustActuallyExist() { + rejects(enabled(), "data-source-ref"); + } + + @Test + void selectedDataSourceMustBeJdbc() { + rejects( + resources( + "not-a-datasource", + new DataSourceTransactionManager(dataSource), + executor()), + "data-source-ref"); + } + + @Test + void selectedManagerMustNotBeReactive() { + rejects( + resources( + dataSource, + new R2dbcTransactionManager(mock(ConnectionFactory.class)), + executor()), + "transaction-manager-ref"); + } + + @Test + void managerMustOwnTheExactSelectedDataSource() { + DataSource other = mock(DataSource.class); + rejects( + resources(dataSource, new DataSourceTransactionManager(other), executor()), + "same DataSource"); + } + + @Test + void executorCannotRunJobsSynchronouslyOnCaller() { + rejects( + resources( + dataSource, + new DataSourceTransactionManager(dataSource), + new SyncTaskExecutor()), + "task-executor-ref"); + } + + @Test + void executorCannotHaveAnUnboundedMaximum() { + var executor = executor(); + executor.setMaxPoolSize(Integer.MAX_VALUE); + rejects( + resources(dataSource, new DataSourceTransactionManager(dataSource), executor), + "core=max"); + } + + @Test + void executorCannotQueueUnboundedWork() { + var executor = executor(); + executor.setQueueCapacity(Integer.MAX_VALUE); + rejects( + resources(dataSource, new DataSourceTransactionManager(dataSource), executor), + "queueCapacity=0"); + } + + @Test + void callerRunsRejectionIsNotAllowed() { + var executor = executor(); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + rejects( + resources(dataSource, new DataSourceTransactionManager(dataSource), executor), + "AbortPolicy"); + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchSchemaTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchSchemaTest.java new file mode 100644 index 000000000..e306e8fcd --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchSchemaTest.java @@ -0,0 +1,186 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.fireflyframework.batch.fixture.PostgresBatchFixture; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.repository.support.ResourcelessJobRepository; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.JdbcTransactionManager; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import javax.sql.DataSource; + +class JdbcBatchSchemaTest extends PostgresBatchFixture { + private ApplicationContextRunner configured(DataSource source) { + return new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + DurableBatchAutoConfiguration.class, + DurableJdbcBatchAutoConfiguration.class)) + .withPropertyValues( + "firefly.batch.durable.enabled=true", + "spring.batch.job.enabled=false", + "firefly.batch.durable.data-source-ref=ownedDataSource", + "firefly.batch.durable.transaction-manager-ref=ownedManager", + "firefly.batch.durable.task-executor-ref=ownedExecutor") + .withBean("ownedDataSource", DataSource.class, () -> source) + .withBean( + "ownedManager", + JdbcTransactionManager.class, + () -> new JdbcTransactionManager(source)) + .withBean( + "ownedExecutor", + ThreadPoolTaskExecutor.class, + PostgresBatchFixture::executor); + } + + @Test + void genuineNativeRepositoryPersistsWithoutRuntimeSchemaOwnership() throws Exception { + DataSource source = schema("complete"); + configured(source) + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(JobRepository.class) + .hasSingleBean(JobOperator.class); + JobRepository repository = context.getBean(JobRepository.class); + assertThat(repository).isNotInstanceOf(ResourcelessJobRepository.class); + var parameters = + new JobParametersBuilder() + .addString("tenant", "owned-tenant", true) + .toJobParameters(); + var instance = repository.createJobInstance("owned-job", parameters); + assertThat(instance.getId()).isPositive(); + assertThat(repository.getJobInstance("owned-job", parameters).getId()) + .isEqualTo(instance.getId()); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT" + + " has_schema_privilege(current_user,current_schema(),'CREATE')", + Boolean.class)) + .isFalse(); + }); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE WHERE" + + " JOB_NAME='owned-job'", + Integer.class)) + .isEqualTo(1); + } + + @Test + void enabledNativeContextSuppliesJdbcBoundStepBuilderHelper() throws Exception { + configured(schema("complete")) + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean( + org.fireflyframework.batch.jdbc.JdbcBatchSteps.class); + }); + } + + @Test + void missingSchemaCannotBeAcceptedAsDurable() throws Exception { + configured(schema("empty")).run(context -> assertThat(context).hasFailed()); + } + + @Test + void missingNativeColumnFailsBeforeJobCreation() throws Exception { + DataSource source = schema("missing-column"); + configured(source).run(context -> assertThat(context).hasFailed()); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isZero(); + } + + @Test + void incompatibleNativeColumnTypeFailsBeforeJobCreation() throws Exception { + DataSource source = schema("wrong-column-type"); + configured(source).run(context -> assertThat(context).hasFailed()); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isZero(); + } + + @Test + void readOnlyMetadataGrantCannotBeAcceptedAsDurableWriter() throws Exception { + configured(schema("read-only")).run(context -> assertThat(context).hasFailed()); + } + + @Test + void missingNativeSequenceFailsBeforeJobCreation() throws Exception { + DataSource source = schema("missing-sequence"); + configured(source).run(context -> assertThat(context).hasFailed()); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isZero(); + } + + @Test + void inaccessibleSchemaFailsReadiness() throws Exception { + configured(schema("no-access")).run(context -> assertThat(context).hasFailed()); + } + + @Test + void explicitNamesIgnoreAnotherPrimaryResource() throws Exception { + DataSource selected = schema("complete"); + DataSource other = schema("empty"); + configured(selected) + .withBean( + "anotherDataSource", + DataSource.class, + () -> other, + definition -> definition.setPrimary(true)) + .withBean( + "anotherManager", + JdbcTransactionManager.class, + () -> new JdbcTransactionManager(other), + definition -> definition.setPrimary(true)) + .run( + context -> { + assertThat(context).hasNotFailed().hasSingleBean(JobRepository.class); + context.getBean(JobRepository.class) + .createJobInstance( + "explicit-selected-job", + new JobParametersBuilder() + .addString("run", "owned-run", true) + .toJobParameters()); + }); + assertThat( + new JdbcTemplate(selected) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isEqualTo(1); + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/MinimalBootRuntimeTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/MinimalBootRuntimeTest.java new file mode 100644 index 000000000..0cb6444c8 --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/MinimalBootRuntimeTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +class MinimalBootRuntimeTest { + @TempDir Path temporary; + + @Test + void actualDefaultBootRuntimeHasNoDatabaseOrJobInfrastructure() throws Exception { + String dependencies = Files.readString(Path.of("target/runtime-classpath.txt")).trim(); + assertThat(dependencies) + .doesNotContain("spring-boot-batch-") + .doesNotContain("spring-boot-jdbc-") + .doesNotContain("spring-boot-starter-batch-"); + String classpath = + Path.of("target/test-classes").toAbsolutePath() + + File.pathSeparator + + Path.of("target/classes").toAbsolutePath() + + File.pathSeparator + + dependencies; + Path log = temporary.resolve("minimal-boot.log"); + Process process = + new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-cp", + classpath, + "org.fireflyframework.batch.fixture.MinimalBootMain") + .redirectErrorStream(true) + .redirectOutput(log.toFile()) + .start(); + try { + assertThat(process.waitFor(Duration.ofSeconds(25).toMillis(), TimeUnit.MILLISECONDS)) + .isTrue(); + assertThat(process.exitValue()).withFailMessage(Files.readString(log)).isZero(); + assertThat(Files.readString(log)).contains("OWNED_MINIMAL_BOOT_NO_RESOURCES=true"); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/MinimalPackagedBootIT.java b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/MinimalPackagedBootIT.java new file mode 100644 index 000000000..b971c0887 --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/MinimalPackagedBootIT.java @@ -0,0 +1,119 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.fireflyframework.batch.fixture.MinimalBootMain; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +class MinimalPackagedBootIT { + private void run(boolean successful, String... arguments) throws Exception { + Path parent = Path.of("target/durable-process-evidence"); + Files.createDirectories(parent); + Path evidence = Files.createTempDirectory(parent, "minimal-").toAbsolutePath(); + Path bootstrap = evidence.resolve("bootstrap"); + for (String name : + List.of( + MinimalBootMain.class.getName(), + MinimalBootMain.class.getName() + "$Application")) { + String resource = name.replace('.', '/') + ".class"; + Path target = bootstrap.resolve(resource); + Files.createDirectories(target.getParent()); + try (var input = MinimalBootMain.class.getClassLoader().getResourceAsStream(resource)) { + if (input == null) throw new IllegalStateException("Owned bootstrap missing"); + Files.write(target, input.readAllBytes()); + } + } + Path artifact = Path.of(System.getProperty("durable.batch.jar")); + assertThat(artifact).isRegularFile(); + String dependencies = Files.readString(Path.of("target/runtime-classpath.txt")).trim(); + assertThat(dependencies) + .doesNotContain( + "spring-boot-batch-", + "spring-boot-jdbc-", + "testcontainers", + "junit", + "target/classes"); + String classpath = + bootstrap + + java.io.File.pathSeparator + + artifact + + java.io.File.pathSeparator + + dependencies; + var command = + new ArrayList<>( + List.of( + Path.of(System.getProperty("java.home"), "bin/java").toString(), + "-Xms16m", + "-Xmx192m", + "-cp", + classpath, + MinimalBootMain.class.getName())); + command.addAll(List.of(arguments)); + Path log = evidence.resolve("java.log"); + var child = + new ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(log.toFile()) + .start(); + try { + assertThat(child.waitFor(25, TimeUnit.SECONDS)).isTrue(); + String output = Files.readString(log); + if (successful) { + assertThat(child.exitValue()).withFailMessage(output).isZero(); + assertThat(output) + .contains( + "OWNED_MINIMAL_BOOT_NO_RESOURCES=true", + "OWNED_MODULE_ORIGIN=" + artifact.toUri().toURL()); + } else { + assertThat(child.exitValue()).withFailMessage(output).isNotZero(); + assertThat(output).contains("data-source-ref"); + } + } finally { + if (child.isAlive()) child.destroyForcibly(); + assertThat(child.waitFor(10, TimeUnit.SECONDS)).isTrue(); + Files.writeString( + evidence.resolve("cleanup.txt"), + "pid=" + child.pid() + "\nalive=" + child.isAlive()); + assertThat(child.isAlive()).isFalse(); + } + } + + @Test + void absentModeHasNoDatabaseEvenWithPackagedModulePresent() throws Exception { + run(true); + } + + @Test + void explicitFalseModeIgnoresIncompleteResourceNames() throws Exception { + run( + true, + "--firefly.batch.durable.enabled=false", + "--firefly.batch.durable.data-source-ref=not-present"); + } + + @Test + void explicitTrueModeRejectsMissingResourcesInActualPackagedBoot() throws Exception { + run(false, "--firefly.batch.durable.enabled=true", "--spring.batch.job.enabled=false"); + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/BatchProcessMain.java b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/BatchProcessMain.java new file mode 100644 index 000000000..53a84e480 --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/BatchProcessMain.java @@ -0,0 +1,131 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.fixture; + +import org.postgresql.ds.PGSimpleDataSource; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.JobRepository; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +/** Test-owned native consumer; its runtime classpath has no test framework or containers. */ +public final class BatchProcessMain { + public static void main(String[] args) throws Exception { + var source = new PGSimpleDataSource(); + source.setURL(required("OWNED_BATCH_URL")); + source.setUser(required("OWNED_BATCH_USER")); + source.setPassword(required("OWNED_BATCH_PASSWORD")); + source.setCurrentSchema(required("OWNED_BATCH_SCHEMA")); + System.out.println( + "OWNED_MODULE_ORIGIN=" + + org.fireflyframework.batch.jdbc.JdbcBatchSteps.class + .getProtectionDomain() + .getCodeSource() + .getLocation()); + String mode = args[0]; + String run = required("OWNED_BATCH_RUN"); + try (var context = CheckpointJobFixture.open(source)) { + var repository = context.getBean(JobRepository.class); + var operator = context.getBean(JobOperator.class); + var job = + CheckpointJobFixture.job( + context, + source, + run, + false, + false, + () -> { + String release = System.getenv("OWNED_BATCH_RELEASE_FILE"); + if (release != null) { + System.out.println("OWNED_HOLD_BEFORE_FOUR=true"); + System.out.flush(); + long deadline = + System.nanoTime() + Duration.ofSeconds(60).toNanos(); + while (!Files.exists(Path.of(release))) { + if (System.nanoTime() > deadline) + throw new IllegalStateException( + "Owned barrier timed out"); + try { + Thread.sleep(20); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Owned barrier interrupted", interrupted); + } + } + } + }); + if (mode.equals("stop")) { + var execution = repository.getJobExecution(Long.parseLong(args[1])); + System.out.println("OWNED_STOP_ACK=" + operator.stop(execution)); + return; + } + org.springframework.batch.core.job.JobExecution started; + if (mode.equals("recover-restart") || mode.equals("restart")) { + var original = repository.getJobExecution(Long.parseLong(args[1])); + if (mode.equals("recover-restart")) { + operator.recover(original); + var observed = repository.getJobExecution(original.getId()); + if (observed.getStatus() != BatchStatus.FAILED) + throw new AssertionError("Recovery was not persisted"); + System.out.println("OWNED_RECOVERED_STORED_STATUS=" + observed.getStatus()); + original = observed; + } + started = operator.restart(original); + } else if (mode.equals("start")) { + String startFile = System.getenv("OWNED_BATCH_START_FILE"); + System.out.println("OWNED_READY_TO_START=true"); + System.out.flush(); + if (startFile != null) { + long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos(); + while (!Files.exists(Path.of(startFile))) { + if (System.nanoTime() > deadline) + throw new IllegalStateException("Owned launch barrier timed out"); + Thread.sleep(20); + } + } + started = + operator.start( + job, + new JobParametersBuilder() + .addString("tenant", required("OWNED_BATCH_TENANT"), true) + .addString("run", run, true) + .toJobParameters()); + } else { + throw new IllegalArgumentException("Unknown owned process mode"); + } + System.out.println("OWNED_EXECUTION_ID=" + started.getId()); + System.out.flush(); + var completed = CheckpointJobFixture.await(repository, started.getId()); + System.out.println("OWNED_FINAL_STATUS=" + completed.getStatus()); + if (completed.getStatus() != BatchStatus.COMPLETED + && completed.getStatus() != BatchStatus.STOPPED) { + throw new AssertionError("Owned execution failed: " + completed.getStatus()); + } + } + } + + private static String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) + throw new IllegalStateException("Missing owned fixture setting: " + name); + return value; + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/CheckpointJobFixture.java b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/CheckpointJobFixture.java new file mode 100644 index 000000000..520b5511a --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/CheckpointJobFixture.java @@ -0,0 +1,176 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.fixture; + +import org.fireflyframework.batch.autoconfigure.DurableBatchAutoConfiguration; +import org.fireflyframework.batch.autoconfigure.DurableJdbcBatchAutoConfiguration; +import org.fireflyframework.batch.jdbc.JdbcBatchSteps; +import org.springframework.batch.core.configuration.support.MapJobRegistry; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.item.ExecutionContext; +import org.springframework.batch.infrastructure.item.ItemStreamReader; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.JdbcTransactionManager; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.time.Duration; + +import javax.sql.DataSource; + +public final class CheckpointJobFixture { + private CheckpointJobFixture() {} + + public static AnnotationConfigApplicationContext open(DataSource source) { + var context = new AnnotationConfigApplicationContext(); + context.getEnvironment() + .getPropertySources() + .addFirst( + new MapPropertySource( + "owned-batch", + java.util.Map.of( + "firefly.batch.durable.enabled", + "true", + "spring.batch.job.enabled", + "false", + "firefly.batch.durable.data-source-ref", + "ownedDataSource", + "firefly.batch.durable.transaction-manager-ref", + "ownedManager", + "firefly.batch.durable.task-executor-ref", + "ownedExecutor"))); + context.registerBean("ownedDataSource", DataSource.class, () -> source); + context.registerBean( + "ownedManager", + JdbcTransactionManager.class, + () -> new JdbcTransactionManager(source)); + context.registerBean( + "ownedExecutor", + ThreadPoolTaskExecutor.class, + () -> { + var executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(0); + executor.setThreadNamePrefix("owned-batch-"); + return executor; + }); + context.registerBean("ownedRegistry", MapJobRegistry.class, MapJobRegistry::new); + context.register( + DurableBatchAutoConfiguration.class, DurableJdbcBatchAutoConfiguration.class); + try { + context.refresh(); + return context; + } catch (RuntimeException | Error failure) { + context.close(); + throw failure; + } + } + + public static Job job( + AnnotationConfigApplicationContext context, + DataSource source, + String run, + boolean failAtFive, + boolean omittedManagerControl) + throws Exception { + return job(context, source, run, failAtFive, omittedManagerControl, () -> {}); + } + + public static Job job( + AnnotationConfigApplicationContext context, + DataSource source, + String run, + boolean failAtFive, + boolean omittedManagerControl, + Runnable beforeFour) + throws Exception { + var repository = context.getBean(JobRepository.class); + var reader = new CursorReader(beforeFour); + var helper = context.getBean(JdbcBatchSteps.class); + var builder = + omittedManagerControl + ? new StepBuilder("owned-chunk", repository).chunk(3) + : helper.chunk("owned-chunk", 3); + var jdbc = new JdbcTemplate(source); + var step = + builder.reader(reader) + .writer( + items -> { + for (Integer item : items) { + jdbc.update( + "INSERT INTO owned_items(run_id,item_no) VALUES" + + " (?,?)", + run, + item); + if (failAtFive && item == 5) + throw new IllegalStateException( + "Owned fixture writer failure at five"); + } + }) + .build(); + Job job = new JobBuilder("owned-checkpoint-job", repository).start(step).build(); + context.getBean(MapJobRegistry.class).register(job); + return job; + } + + public static JobExecution await(JobRepository repository, long executionId) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos(); + while (System.nanoTime() < deadline) { + JobExecution current = repository.getJobExecution(executionId); + if (current != null && !current.isRunning() && current.getEndTime() != null) + return current; + Thread.sleep(20); + } + throw new AssertionError( + "Owned execution did not reach a stored terminal state: " + executionId); + } + + public static final class CursorReader implements ItemStreamReader { + private int cursor; + private final Runnable beforeFour; + + public CursorReader() { + this(() -> {}); + } + + public CursorReader(Runnable beforeFour) { + this.beforeFour = beforeFour; + } + + @Override + public void open(ExecutionContext context) { + cursor = context.getInt("owned.cursor", 0); + } + + @Override + public Integer read() { + if (cursor == 3) beforeFour.run(); + return cursor >= 10 ? null : ++cursor; + } + + @Override + public void update(ExecutionContext context) { + context.putInt("owned.cursor", cursor); + } + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/MinimalBootMain.java b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/MinimalBootMain.java new file mode 100644 index 000000000..44efce46e --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/MinimalBootMain.java @@ -0,0 +1,56 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.fixture; + +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; + +import javax.sql.DataSource; + +public class MinimalBootMain { + @SpringBootConfiguration + @EnableAutoConfiguration + static class Application {} + + public static void main(String[] args) { + try (var context = + new SpringApplicationBuilder(Application.class) + .web(WebApplicationType.NONE) + .properties("spring.main.banner-mode=off", "logging.level.root=WARN") + .run(args)) { + if (!context.getBeansOfType(DataSource.class).isEmpty() + || !context.getBeansOfType(JobRepository.class).isEmpty() + || !context.getBeansOfType(JobOperator.class).isEmpty()) { + throw new AssertionError( + "Disabled module created database or Batch infrastructure"); + } + System.out.println( + "OWNED_MODULE_ORIGIN=" + + org.fireflyframework.batch.jdbc.JdbcBatchSteps.class + .getProtectionDomain() + .getCodeSource() + .getLocation()); + System.out.println("OWNED_MINIMAL_BOOT_NO_RESOURCES=true"); + } catch (RuntimeException failure) { + failure.printStackTrace(System.err); + throw failure; + } + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/OwnedRunFixture.java b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/OwnedRunFixture.java new file mode 100644 index 000000000..1a820ac78 --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/OwnedRunFixture.java @@ -0,0 +1,199 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.fixture; + +import org.fireflyframework.batch.jdbc.JdbcBatchSteps; +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.security.spi.PolicyDecisionPort; +import org.springframework.batch.core.configuration.support.MapJobRegistry; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.Map; + +import javax.sql.DataSource; + +/** Application-owned synthetic recipe, not a framework financial run authority. */ +public final class OwnedRunFixture { + public record Intent(String tenant, String run, String canonical) { + public String digest() { + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + } + + private final JdbcTemplate jdbc; + private final TransactionTemplate transaction; + private final PolicyDecisionPort policy; + private final SecurityPrincipal servicePrincipal; + + public OwnedRunFixture( + DataSource source, + DataSourceTransactionManager manager, + PolicyDecisionPort policy, + SecurityPrincipal servicePrincipal) { + this.jdbc = new JdbcTemplate(source); + this.transaction = new TransactionTemplate(manager); + this.policy = policy; + this.servicePrincipal = servicePrincipal; + } + + public String claim(Intent intent) { + authorize(intent); + return transaction.execute( + status -> { + int inserted = + jdbc.update( + "INSERT INTO" + + " owned_run_claim(tenant,run_id,canonical,digest,state)" + + " VALUES (?,?,?,?,'OPEN') ON CONFLICT(tenant,run_id) DO" + + " NOTHING", + intent.tenant(), + intent.run(), + intent.canonical(), + intent.digest()); + var stored = + jdbc.queryForMap( + "SELECT canonical,digest FROM owned_run_claim WHERE tenant=?" + + " AND run_id=? FOR UPDATE", + intent.tenant(), + intent.run()); + match(intent, stored); + return inserted == 1 ? "CREATED" : "REPLAY"; + }); + } + + private void authorize(Intent intent) { + if (servicePrincipal == null + || !intent.tenant().equals(servicePrincipal.tenantId()) + || !servicePrincipal.hasScope("owned-run:execute")) { + throw new IllegalStateException("Current owner service tenant or scope denied"); + } + try { + var publisher = + policy.authorize( + servicePrincipal, + "owned-run:execute", + "owned-run:" + intent.tenant() + ":" + intent.run(), + Map.of( + "tenant", + intent.tenant(), + "run", + intent.run(), + "intentDigest", + intent.digest())); + var decision = + publisher == null ? null : publisher.block(java.time.Duration.ofSeconds(1)); + if (decision == null || !decision.granted() || !decision.obligations().isEmpty()) { + throw new IllegalStateException("Current owner service policy denied"); + } + } catch (RuntimeException failure) { + throw new IllegalStateException("Current owner service policy denied", failure); + } + } + + private void requireMatchingClaim(Intent intent) { + match( + intent, + jdbc.queryForMap( + "SELECT canonical,digest FROM owned_run_claim WHERE tenant=? AND run_id=?", + intent.tenant(), + intent.run())); + } + + private static void match(Intent intent, Map stored) { + if (!intent.canonical().equals(stored.get("canonical")) + || !intent.digest().equals(stored.get("digest"))) { + throw new IllegalStateException("Owner run intent conflicts with immutable claim"); + } + } + + public Job job(AnnotationConfigApplicationContext context, Intent intent, boolean failAtFive) + throws Exception { + requireMatchingClaim(intent); + var repository = context.getBean(JobRepository.class); + var reader = new CheckpointJobFixture.CursorReader(); + var step = + context.getBean(JdbcBatchSteps.class) + .chunk("owned-run-step", 3) + .reader(reader) + .writer( + items -> { + for (Integer item : items) { + authorize(intent); + requireMatchingClaim(intent); + jdbc.update( + "INSERT INTO owned_run_items(tenant,run_id,item_no)" + + " VALUES (?,?,?)", + intent.tenant(), + intent.run(), + item); + if (failAtFive && item == 5) + throw new IllegalStateException( + "Owned recipe fixture failure at five"); + } + }) + .build(); + Job job = new JobBuilder("owned-run-job", repository).start(step).build(); + context.getBean(MapJobRegistry.class).register(job); + return job; + } + + public boolean completeOnlyWithStoredAcknowledgments(Intent intent) { + authorize(intent); + return transaction.execute( + status -> { + requireMatchingClaim(intent); + Integer items = + jdbc.queryForObject( + "SELECT count(*) FROM owned_run_items WHERE tenant=? AND" + + " run_id=?", + Integer.class, + intent.tenant(), + intent.run()); + Integer missing = + jdbc.queryForObject( + "SELECT count(*) FROM owned_run_items i LEFT JOIN" + + " owned_run_receipts r ON r.tenant=i.tenant AND" + + " r.run_id=i.run_id AND r.item_no=i.item_no WHERE" + + " i.tenant=? AND i.run_id=? AND r.item_no IS NULL", + Integer.class, + intent.tenant(), + intent.run()); + if (items != 10 || missing != 0) return false; + jdbc.update( + "UPDATE owned_run_claim SET state='COMPLETED' WHERE tenant=? AND" + + " run_id=?", + intent.tenant(), + intent.run()); + return true; + }); + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchFixture.java b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchFixture.java new file mode 100644 index 000000000..ab5299dee --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchFixture.java @@ -0,0 +1,121 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.fixture; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.postgresql.ds.PGSimpleDataSource; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import javax.sql.DataSource; + +public abstract class PostgresBatchFixture { + protected static PostgreSQLContainer database; + protected static String role; + protected static String password; + + @BeforeAll + static void openPrivateDatabase() throws Exception { + database = + new PostgreSQLContainer<>("postgres:17-alpine") + .withDatabaseName("owned_batch_fixture") + .withUsername("owned_bootstrap") + .withPassword(UUID.randomUUID().toString()); + database.start(); + role = "batch_runtime_" + UUID.randomUUID().toString().replace("-", ""); + password = UUID.randomUUID().toString(); + try (var connection = owner().getConnection(); + var statement = connection.createStatement()) { + statement.execute( + "CREATE ROLE " + + role + + " LOGIN NOSUPERUSER NOBYPASSRLS PASSWORD '" + + password + + "'"); + } + } + + @AfterAll + static void closePrivateDatabase() { + if (database != null) database.stop(); + } + + protected static PGSimpleDataSource owner() { + var source = new PGSimpleDataSource(); + source.setURL(database.getJdbcUrl()); + source.setUser(database.getUsername()); + source.setPassword(database.getPassword()); + return source; + } + + protected static DataSource schema(String variant) throws Exception { + String name = "owned_" + UUID.randomUUID().toString().replace("-", ""); + String ddl; + try (var resource = + PostgresBatchFixture.class.getResourceAsStream( + "/org/springframework/batch/core/schema-postgresql.sql")) { + if (resource == null) + throw new IllegalStateException("Native Batch PostgreSQL schema missing"); + ddl = new String(resource.readAllBytes(), StandardCharsets.UTF_8); + } + if (variant.equals("missing-column")) ddl = ddl.replace("\tFILTER_COUNT BIGINT,", ""); + if (variant.equals("wrong-column-type")) + ddl = ddl.replace("\tFILTER_COUNT BIGINT,", "\tFILTER_COUNT VARCHAR(100),"); + if (variant.equals("missing-sequence")) + ddl = + ddl.replace( + "CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO" + + " CYCLE;", + ""); + try (var connection = owner().getConnection(); + var statement = connection.createStatement()) { + statement.execute("CREATE SCHEMA " + name); + connection.setSchema(name); + if (!variant.equals("empty")) statement.execute(ddl); + if (!variant.equals("no-access")) { + statement.execute("GRANT USAGE ON SCHEMA " + name + " TO " + role); + statement.execute( + "GRANT " + + (variant.equals("read-only") ? "SELECT" : "SELECT,INSERT,UPDATE") + + " ON ALL TABLES IN SCHEMA " + + name + + " TO " + + role); + statement.execute( + "GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA " + name + " TO " + role); + } + } + var runtime = new PGSimpleDataSource(); + runtime.setURL(database.getJdbcUrl()); + runtime.setUser(role); + runtime.setPassword(password); + runtime.setCurrentSchema(name); + return runtime; + } + + protected static ThreadPoolTaskExecutor executor() { + var executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(0); + executor.setThreadNamePrefix("owned-batch-"); + return executor; + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/project/ScannedNativeProviders.java b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/project/ScannedNativeProviders.java new file mode 100644 index 000000000..1c188872f --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/project/ScannedNativeProviders.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.fixture.project; + +import org.springframework.batch.core.configuration.support.MapJobRegistry; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.launch.support.JobOperatorFactoryBean; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.repository.support.JdbcJobRepositoryFactoryBean; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; + +@Configuration(proxyBeanMethods = false) +public class ScannedNativeProviders { + @Bean + JobRepository projectRepository( + @Qualifier("ownedDataSource") DataSource source, + @Qualifier("ownedManager") PlatformTransactionManager manager) + throws Exception { + var factory = new JdbcJobRepositoryFactoryBean(); + factory.setDataSource(source); + factory.setTransactionManager(manager); + factory.setDatabaseType("POSTGRES"); + factory.afterPropertiesSet(); + return factory.getObject(); + } + + @Bean + JobOperator projectOperator( + JobRepository repository, + @Qualifier("ownedManager") PlatformTransactionManager manager, + @Qualifier("ownedExecutor") ThreadPoolTaskExecutor executor) + throws Exception { + var factory = new JobOperatorFactoryBean(); + factory.setJobRepository(repository); + factory.setJobRegistry(new MapJobRegistry()); + factory.setTransactionManager(manager); + factory.setTaskExecutor(executor); + factory.afterPropertiesSet(); + return factory.getObject(); + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchConcurrencyTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchConcurrencyTest.java new file mode 100644 index 000000000..85ccde2d3 --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchConcurrencyTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.fireflyframework.batch.fixture.CheckpointJobFixture; +import org.fireflyframework.batch.fixture.PostgresBatchFixture; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.configuration.support.MapJobRegistry; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +class JdbcBatchConcurrencyTest extends PostgresBatchFixture { + private JobParameters parameters(String tenant, String run) { + return new JobParametersBuilder() + .addString("tenant", tenant, true) + .addString("run", run, true) + .toJobParameters(); + } + + @Test + void runningAndCompletedDuplicateLaunchesCannotExecuteAgain() throws Exception { + var source = schema("complete"); + try (var context = CheckpointJobFixture.open(source)) { + var repository = context.getBean(JobRepository.class); + var operator = context.getBean(JobOperator.class); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var effects = new java.util.concurrent.atomic.AtomicInteger(); + var helper = context.getBean(JdbcBatchSteps.class); + var step = + helper.tasklet( + "owned-admission-step", + (contribution, chunk) -> { + entered.countDown(); + if (!release.await(10, TimeUnit.SECONDS)) + throw new IllegalStateException( + "Owned barrier expired"); + effects.incrementAndGet(); + return RepeatStatus.FINISHED; + }) + .build(); + Job job = new JobBuilder("owned-admission-job", repository).start(step).build(); + context.getBean(MapJobRegistry.class).register(job); + var parameters = parameters("owned-a", "stable-run"); + JobExecution first = operator.start(job, parameters); + try { + assertThat(entered.await(10, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> operator.start(job, parameters)) + .isInstanceOf( + org.springframework.batch.core.launch + .JobExecutionAlreadyRunningException.class); + assertThat(effects.get()).isZero(); + } finally { + release.countDown(); + } + assertThat(CheckpointJobFixture.await(repository, first.getId()).getStatus()) + .isEqualTo(BatchStatus.COMPLETED); + assertThatThrownBy(() -> operator.start(job, parameters)) + .isInstanceOf( + org.springframework.batch.core.launch + .JobInstanceAlreadyCompleteException.class); + assertThat(effects.get()).isEqualTo(1); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE", + Integer.class)) + .isEqualTo(1); + assertThat(repository.getJobExecutions(first.getJobInstance())).hasSize(1); + var otherTenant = + repository.createJobInstance( + job.getName(), parameters("owned-b", "stable-run")); + assertThat(otherTenant.getId()).isNotEqualTo(first.getJobInstanceId()); + } + } + + @Test + void fullBoundedExecutorRejectsThirdExecutionWithoutCallerThreadEffects() throws Exception { + var source = schema("complete"); + try (var context = CheckpointJobFixture.open(source)) { + var repository = context.getBean(JobRepository.class); + var operator = context.getBean(JobOperator.class); + var entered = new CountDownLatch(2); + var release = new CountDownLatch(1); + var rejectedEntered = new AtomicBoolean(); + var effects = new java.util.concurrent.atomic.AtomicInteger(); + var helper = context.getBean(JdbcBatchSteps.class); + var step = + helper.tasklet( + "owned-bound-step", + (contribution, chunk) -> { + if (contribution + .getStepExecution() + .getJobParameters() + .getString("run") + .equals("third")) { + rejectedEntered.set(true); + } + entered.countDown(); + if (!release.await(10, TimeUnit.SECONDS)) + throw new IllegalStateException( + "Owned barrier expired"); + effects.incrementAndGet(); + return RepeatStatus.FINISHED; + }) + .build(); + Job job = new JobBuilder("owned-bound-job", repository).start(step).build(); + context.getBean(MapJobRegistry.class).register(job); + JobExecution first = operator.start(job, parameters("owned", "first")); + JobExecution second = operator.start(job, parameters("owned", "second")); + try { + assertThat(entered.await(10, TimeUnit.SECONDS)).isTrue(); + JobExecution third = operator.start(job, parameters("owned", "third")); + assertThat(repository.getJobExecution(third.getId()).getStatus()) + .isEqualTo(BatchStatus.FAILED); + assertThat(rejectedEntered.get()).isFalse(); + assertThat(effects.get()).isZero(); + } finally { + release.countDown(); + } + assertThat(CheckpointJobFixture.await(repository, first.getId()).getStatus()) + .isEqualTo(BatchStatus.COMPLETED); + assertThat(CheckpointJobFixture.await(repository, second.getId()).getStatus()) + .isEqualTo(BatchStatus.COMPLETED); + assertThat(effects.get()).isEqualTo(2); + assertThat(rejectedEntered.get()).isFalse(); + } + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java new file mode 100644 index 000000000..0dc131cd6 --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java @@ -0,0 +1,371 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.fireflyframework.batch.fixture.BatchProcessMain; +import org.fireflyframework.batch.fixture.CheckpointJobFixture; +import org.fireflyframework.batch.fixture.PostgresBatchFixture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.postgresql.ds.PGSimpleDataSource; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.sql.DataSource; + +class JdbcBatchProcessDatabaseIT extends PostgresBatchFixture { + private Path temporary; + + @BeforeEach + void preserveOwnedProcessEvidence() throws Exception { + Path parent = Path.of("target/durable-process-evidence"); + Files.createDirectories(parent); + temporary = Files.createTempDirectory(parent, "owned-").toAbsolutePath(); + System.out.println("OWNED_PROCESS_EVIDENCE=" + temporary); + } + + private final List children = new ArrayList<>(); + + @AfterEach + void cleanupOwnedChildren() throws Exception { + var observed = new StringBuilder(); + for (Process child : children) { + if (child.isAlive()) child.destroyForcibly(); + assertThat(child.waitFor(10, TimeUnit.SECONDS)).isTrue(); + assertThat(child.isAlive()).isFalse(); + observed.append(child.pid()).append("\t").append(child.isAlive()).append("\n"); + } + Files.writeString(temporary.resolve("owned-child-cleanup.tsv"), observed); + } + + private DataSource prepared() throws Exception { + DataSource source = schema("complete"); + String selected; + try (var connection = source.getConnection()) { + selected = connection.getSchema(); + } + try (var connection = owner().getConnection(); + var statement = connection.createStatement()) { + connection.setSchema(selected); + statement.execute( + "CREATE TABLE owned_items(run_id varchar(100) NOT NULL,item_no integer NOT" + + " NULL,PRIMARY KEY(run_id,item_no))"); + statement.execute("GRANT SELECT,INSERT ON owned_items TO " + role); + } + return source; + } + + private Process child(DataSource source, String name, String mode, Long execution, Path release) + throws Exception { + return child(source, name, mode, execution, release, null); + } + + private Process child( + DataSource source, + String name, + String mode, + Long execution, + Path release, + Path startFile) + throws Exception { + Path bootstrap = temporary.resolve(name + "-bootstrap"); + for (Class type : + List.of( + BatchProcessMain.class, + CheckpointJobFixture.class, + CheckpointJobFixture.CursorReader.class)) { + String resource = type.getName().replace('.', '/') + ".class"; + Path target = bootstrap.resolve(resource); + Files.createDirectories(target.getParent()); + try (var input = type.getClassLoader().getResourceAsStream(resource)) { + if (input == null) throw new IllegalStateException("Owned bootstrap class missing"); + Files.write(target, input.readAllBytes()); + } + } + Path artifact = Path.of(System.getProperty("durable.batch.jar")); + assertThat(artifact).isRegularFile(); + String runtime = Files.readString(Path.of("target/runtime-classpath.txt")).trim(); + assertThat(runtime) + .doesNotContain("spring-boot-batch", "testcontainers", "junit", "target/classes"); + String driver = + Path.of( + PGSimpleDataSource.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()) + .toString(); + String classpath = + bootstrap + + java.io.File.pathSeparator + + artifact + + java.io.File.pathSeparator + + runtime + + java.io.File.pathSeparator + + driver; + var pins = new StringBuilder(); + for (String entry : + classpath.split(java.util.regex.Pattern.quote(java.io.File.pathSeparator))) { + Path input = Path.of(entry); + if (Files.isRegularFile(input)) { + pins.append(input.toAbsolutePath()) + .append("\t") + .append( + java.util.HexFormat.of() + .formatHex( + java.security.MessageDigest.getInstance("SHA-256") + .digest(Files.readAllBytes(input)))) + .append("\n"); + } + } + Files.writeString(temporary.resolve(name + "-jar-pins.tsv"), pins); + var command = + new ArrayList<>( + List.of( + Path.of(System.getProperty("java.home"), "bin/java").toString(), + "-Xms16m", + "-Xmx192m", + "-cp", + classpath, + BatchProcessMain.class.getName(), + mode)); + if (execution != null) command.add(Long.toString(execution)); + var builder = + new ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(temporary.resolve(name + ".log").toFile()); + var environment = builder.environment(); + environment.put("OWNED_BATCH_URL", database.getJdbcUrl()); + environment.put("OWNED_BATCH_USER", role); + environment.put("OWNED_BATCH_PASSWORD", password); + try (var connection = source.getConnection()) { + environment.put("OWNED_BATCH_SCHEMA", connection.getSchema()); + } + environment.put("OWNED_BATCH_RUN", "stable-process-run"); + environment.put("OWNED_BATCH_TENANT", "owned-tenant"); + environment.remove("OWNED_BATCH_RELEASE_FILE"); + environment.remove("OWNED_BATCH_START_FILE"); + if (startFile != null) environment.put("OWNED_BATCH_START_FILE", startFile.toString()); + if (release != null) environment.put("OWNED_BATCH_RELEASE_FILE", release.toString()); + Process started = builder.start(); + children.add(started); + return started; + } + + private void held(DataSource source, Process process, String name) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); + Path log = temporary.resolve(name + ".log"); + while (System.nanoTime() < deadline) { + if (!process.isAlive()) throw new AssertionError(Files.readString(log)); + if (Files.readString(log).contains("OWNED_HOLD_BEFORE_FOUR=true")) { + assertThat(rows(source)).containsExactly(1, 2, 3); + return; + } + Thread.sleep(20); + } + throw new AssertionError( + "Owned child did not reach first committed checkpoint: " + Files.readString(log)); + } + + private List rows(DataSource source) { + return new JdbcTemplate(source) + .queryForList("SELECT item_no FROM owned_items ORDER BY item_no", Integer.class); + } + + private long lastExecution(DataSource source) { + return new JdbcTemplate(source) + .queryForObject( + "SELECT max(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION", Long.class); + } + + private void success(Process process, String name) throws Exception { + assertThat(process.waitFor(30, TimeUnit.SECONDS)).isTrue(); + String output = Files.readString(temporary.resolve(name + ".log")); + assertThat(process.exitValue()).withFailMessage(output).isZero(); + assertThat(output) + .contains( + "OWNED_MODULE_ORIGIN=" + + Path.of(System.getProperty("durable.batch.jar")).toUri().toURL()); + } + + @Test + void simultaneousIndependentProcessesAdmitOneLiveExecutionAndOneEffectStream() + throws Exception { + DataSource source = prepared(); + Path startFile = temporary.resolve("simultaneous-start"); + Path release = temporary.resolve("winner-release"); + Process first = child(source, "race-first", "start", null, release, startFile); + Process second = child(source, "race-second", "start", null, release, startFile); + long readyDeadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); + while (!Files.readString(temporary.resolve("race-first.log")) + .contains("OWNED_READY_TO_START=true") + || !Files.readString(temporary.resolve("race-second.log")) + .contains("OWNED_READY_TO_START=true")) { + if (!first.isAlive() || !second.isAlive() || System.nanoTime() > readyDeadline) { + throw new AssertionError( + Files.readString(temporary.resolve("race-first.log")) + + Files.readString(temporary.resolve("race-second.log"))); + } + Thread.sleep(20); + } + Files.writeString(startFile, "owned synchronized admission"); + long raceDeadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); + while (first.isAlive() == second.isAlive()) { + if (System.nanoTime() > raceDeadline) + throw new AssertionError("Native concurrent admission did not settle"); + Thread.sleep(20); + } + Process winner = first.isAlive() ? first : second; + Process loser = first.isAlive() ? second : first; + String winnerName = first.isAlive() ? "race-first" : "race-second"; + String loserName = first.isAlive() ? "race-second" : "race-first"; + held(source, winner, winnerName); + assertThat(loser.waitFor(10, TimeUnit.SECONDS)).isTrue(); + assertThat(loser.exitValue()).isNotZero(); + assertThat(Files.readString(temporary.resolve(loserName + ".log"))) + .containsAnyOf( + "JobExecutionAlreadyRunningException", + "DuplicateKeyException", + "CannotSerializeTransactionException", + "JobInstanceAlreadyExistsException", + "java.lang.IllegalStateException: JobInstance must not already exist"); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isEqualTo(1); + Files.writeString(release, "owned admitted process may finish"); + success(winner, winnerName); + assertThat(rows(source)).containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + var jdbc = new JdbcTemplate(source); + assertThat( + jdbc.queryForObject( + "SELECT count(*) FROM BATCH_JOB_EXECUTION WHERE STATUS='COMPLETED'", + Integer.class)) + .isEqualTo(1); + assertThat( + jdbc.queryForObject( + "SELECT count(*) FROM BATCH_JOB_EXECUTION WHERE STATUS IN" + + " ('STARTING','STARTED','STOPPING')", + Integer.class)) + .isZero(); + Files.writeString( + temporary.resolve("concurrent-observation.txt"), + "winner=" + winnerName + "\nloser=" + loserName + "\nrows=" + rows(source)); + } + + @Test + void killedProcessRequiresObservedDeathThenNativeRecoverAndRestartFromPersistedCursor() + throws Exception { + DataSource source = prepared(); + Process original = + child(source, "before-kill", "start", null, temporary.resolve("never-release")); + held(source, original, "before-kill"); + long originalId = lastExecution(source); + original.destroyForcibly(); + assertThat(original.waitFor(10, TimeUnit.SECONDS)).isTrue(); + assertThat(original.isAlive()).isFalse(); + assertThat(rows(source)).containsExactly(1, 2, 3); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT STATUS FROM BATCH_JOB_EXECUTION WHERE" + + " JOB_EXECUTION_ID=?", + String.class, + originalId)) + .isEqualTo("STARTED"); + Files.writeString( + temporary.resolve("kill-observation.txt"), + "old-process-alive=" + + original.isAlive() + + "\nrows-before-recovery=" + + rows(source) + + "\noriginal-execution=" + + originalId); + Process replacement = child(source, "after-kill", "recover-restart", originalId, null); + success(replacement, "after-kill"); + assertThat(Files.readString(temporary.resolve("after-kill.log"))) + .contains("OWNED_RECOVERED_STORED_STATUS=FAILED", "OWNED_FINAL_STATUS=COMPLETED"); + assertThat(rows(source)).containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + Files.writeString(temporary.resolve("kill-final-observation.txt"), "rows=" + rows(source)); + var jdbc = new JdbcTemplate(source); + assertThat(jdbc.queryForObject("SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT count(*) FROM BATCH_JOB_EXECUTION", Integer.class)) + .isEqualTo(2); + assertThat( + jdbc.queryForObject( + "SELECT STATUS FROM BATCH_JOB_EXECUTION WHERE JOB_EXECUTION_ID=?", + String.class, + originalId)) + .isEqualTo("FAILED"); + } + + @Test + void separateProcessRequestsStopAndFreshProcessRestartsSameInstance() throws Exception { + DataSource source = prepared(); + Path release = temporary.resolve("release-after-stop"); + Process original = child(source, "before-stop", "start", null, release); + held(source, original, "before-stop"); + long originalId = lastExecution(source); + Process stopper = child(source, "stopper", "stop", originalId, null); + success(stopper, "stopper"); + assertThat(Files.readString(temporary.resolve("stopper.log"))) + .contains("OWNED_STOP_ACK=true"); + Files.writeString(release, "owned release after actual stop acknowledgment"); + success(original, "before-stop"); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT STATUS FROM BATCH_JOB_EXECUTION WHERE" + + " JOB_EXECUTION_ID=?", + String.class, + originalId)) + .isEqualTo("STOPPED"); + List stoppedRows = rows(source); + assertThat(stoppedRows.size()).isBetween(3, 6); + assertThat(stoppedRows) + .isEqualTo( + java.util.stream.IntStream.rangeClosed(1, stoppedRows.size()) + .boxed() + .toList()); + Files.writeString( + temporary.resolve("stop-observation.txt"), + "stopped-rows=" + stoppedRows + "\noriginal-execution=" + originalId); + Process replacement = child(source, "after-stop", "restart", originalId, null); + success(replacement, "after-stop"); + assertThat(rows(source)).containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isEqualTo(1); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM BATCH_JOB_EXECUTION", Integer.class)) + .isEqualTo(2); + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchStepsTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchStepsTest.java new file mode 100644 index 000000000..354b043fd --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchStepsTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.jdbc; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.repository.support.ResourcelessJobRepository; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +class JdbcBatchStepsTest { + private JdbcBatchSteps steps() { + return new JdbcBatchSteps( + new ResourcelessJobRepository(), + new DataSourceTransactionManager(new DriverManagerDataSource())); + } + + @Test + void blankStepNamesAreRejectedBeforeReturningNativeBuilder() { + assertThatThrownBy(() -> steps().chunk(" ", 3)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> steps().tasklet("", (contribution, context) -> null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void nonpositiveChunkIsRejected() { + assertThatThrownBy(() -> steps().chunk("owned-step", 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> steps().chunk("owned-step", -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void nullTaskletIsRejected() { + assertThatThrownBy(() -> steps().tasklet("owned-step", null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcCheckpointDatabaseTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcCheckpointDatabaseTest.java new file mode 100644 index 000000000..270ab04b6 --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcCheckpointDatabaseTest.java @@ -0,0 +1,184 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.fireflyframework.batch.fixture.CheckpointJobFixture; +import org.fireflyframework.batch.fixture.PostgresBatchFixture; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +import javax.sql.DataSource; + +class JdbcCheckpointDatabaseTest extends PostgresBatchFixture { + private DataSource prepared() throws Exception { + DataSource source = schema("complete"); + String selected; + try (var connection = source.getConnection()) { + selected = connection.getSchema(); + } + try (var connection = owner().getConnection(); + var statement = connection.createStatement()) { + connection.setSchema(selected); + statement.execute( + "CREATE TABLE owned_items(run_id varchar(100) NOT NULL,item_no integer NOT" + + " NULL,PRIMARY KEY(run_id,item_no))"); + statement.execute("GRANT SELECT,INSERT ON owned_items TO " + role); + } + return source; + } + + private List rows(DataSource source) { + return new JdbcTemplate(source) + .queryForList("SELECT item_no FROM owned_items ORDER BY item_no", Integer.class); + } + + @Test + void failedChunkRollsBackBothItemsAndCheckpointThenRestartsInFreshContext() throws Exception { + DataSource source = prepared(); + var parameters = + new JobParametersBuilder() + .addString("tenant", "owned-tenant", true) + .addString("run", "stable-owner-run", true) + .toJobParameters(); + long failedId; + long instanceId; + try (var first = CheckpointJobFixture.open(source)) { + var repository = first.getBean(JobRepository.class); + var job = CheckpointJobFixture.job(first, source, "stable-owner-run", true, false); + JobExecution failed = + CheckpointJobFixture.await( + repository, + first.getBean(JobOperator.class).start(job, parameters).getId()); + failedId = failed.getId(); + instanceId = failed.getJobInstanceId(); + assertThat(failed.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat(rows(source)).containsExactly(1, 2, 3); + var step = repository.getLastStepExecution(failed.getJobInstance(), "owned-chunk"); + assertThat(step.getExecutionContext().getInt("owned.cursor")).isEqualTo(3); + assertThat(step.getRollbackCount()).isPositive(); + } + try (var second = CheckpointJobFixture.open(source)) { + var repository = second.getBean(JobRepository.class); + JobExecution original = repository.getJobExecution(failedId); + assertThat(original.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat( + repository + .getLastStepExecution(original.getJobInstance(), "owned-chunk") + .getExecutionContext() + .getInt("owned.cursor")) + .isEqualTo(3); + CheckpointJobFixture.job(second, source, "stable-owner-run", false, false); + JobExecution completed = + CheckpointJobFixture.await( + repository, + second.getBean(JobOperator.class).restart(original).getId()); + assertThat(completed.getId()).isNotEqualTo(failedId); + assertThat(completed.getJobInstanceId()).isEqualTo(instanceId); + assertThat(completed.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(repository.getJobExecution(failedId).getStatus()) + .isEqualTo(BatchStatus.FAILED); + assertThat(repository.getJobExecutions(completed.getJobInstance())).hasSize(2); + assertThat( + repository + .getLastStepExecution(completed.getJobInstance(), "owned-chunk") + .getExecutionContext() + .getInt("owned.cursor")) + .isEqualTo(10); + assertThat(rows(source)).containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + } + } + + @Test + void suppliedTaskletBuilderRollsBackSameResourceWritesOnFailure() throws Exception { + DataSource source = prepared(); + try (var context = CheckpointJobFixture.open(source)) { + var repository = context.getBean(JobRepository.class); + var helper = context.getBean(JdbcBatchSteps.class); + var step = + helper.tasklet( + "owned-tasklet", + (contribution, chunkContext) -> { + new JdbcTemplate(source) + .update( + "INSERT INTO owned_items(run_id,item_no)" + + " VALUES (?,?)", + "tasklet-run", + 1); + throw new IllegalStateException("Owned tasklet failure"); + }) + .build(); + var job = + new org.springframework.batch.core.job.builder.JobBuilder( + "owned-tasklet-job", repository) + .start(step) + .build(); + context.getBean( + org.springframework.batch.core.configuration.support.MapJobRegistry + .class) + .register(job); + var failed = + CheckpointJobFixture.await( + repository, + context.getBean(JobOperator.class) + .start( + job, + new JobParametersBuilder() + .addString("run", "tasklet-run", true) + .toJobParameters()) + .getId()); + assertThat(failed.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat(rows(source)).isEmpty(); + assertThat( + repository + .getLastStepExecution(failed.getJobInstance(), "owned-tasklet") + .getRollbackCount()) + .isPositive(); + } + } + + @Test + void nativeOmittedManagerControlLeavesPartialItemsDespiteFailedMetadata() throws Exception { + DataSource source = prepared(); + try (var context = CheckpointJobFixture.open(source)) { + var repository = context.getBean(JobRepository.class); + var job = + CheckpointJobFixture.job( + context, source, "omitted-manager-control", true, true); + var failed = + CheckpointJobFixture.await( + repository, + context.getBean(JobOperator.class) + .start( + job, + new JobParametersBuilder() + .addString( + "run", "omitted-manager-control", true) + .toJobParameters()) + .getId()); + assertThat(failed.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat(rows(source)).containsExactly(1, 2, 3, 4, 5); + } + } +} diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/OwnedRunBoundaryDatabaseTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/OwnedRunBoundaryDatabaseTest.java new file mode 100644 index 000000000..9cb33d04e --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/OwnedRunBoundaryDatabaseTest.java @@ -0,0 +1,274 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.fireflyframework.batch.fixture.CheckpointJobFixture; +import org.fireflyframework.batch.fixture.OwnedRunFixture; +import org.fireflyframework.batch.fixture.PostgresBatchFixture; +import org.fireflyframework.security.api.domain.Decision; +import org.fireflyframework.security.api.domain.Obligation; +import org.fireflyframework.security.core.policy.EmbeddedPolicyDecisionAdapter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.JdbcTransactionManager; + +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import javax.sql.DataSource; + +class OwnedRunBoundaryDatabaseTest extends PostgresBatchFixture { + private DataSource prepared() throws Exception { + DataSource source = schema("complete"); + String selected; + try (var connection = source.getConnection()) { + selected = connection.getSchema(); + } + try (var connection = owner().getConnection(); + var statement = connection.createStatement()) { + connection.setSchema(selected); + statement.execute( + "CREATE TABLE owned_run_claim(tenant varchar(100),run_id varchar(100),canonical" + + " text NOT NULL,digest varchar(64) NOT NULL,state varchar(20) NOT" + + " NULL,PRIMARY KEY(tenant,run_id));CREATE TABLE owned_run_items(tenant" + + " varchar(100),run_id varchar(100),item_no integer,PRIMARY" + + " KEY(tenant,run_id,item_no),FOREIGN KEY(tenant,run_id) REFERENCES" + + " owned_run_claim(tenant,run_id));CREATE TABLE owned_run_receipts(tenant" + + " varchar(100),run_id varchar(100),item_no integer,PRIMARY" + + " KEY(tenant,run_id,item_no),FOREIGN KEY(tenant,run_id,item_no)" + + " REFERENCES owned_run_items(tenant,run_id,item_no));GRANT" + + " SELECT,INSERT,UPDATE ON owned_run_claim TO " + + role + + ";" + + "GRANT SELECT,INSERT ON owned_run_items,owned_run_receipts TO " + + role); + } + return source; + } + + private OwnedRunFixture.Intent intent(String tenant, String model) { + return new OwnedRunFixture.Intent( + tenant, + "stable-run", + "book=fictional-book;cutoff=2026-10-22T00:00:00Z;" + + "snapshot=fixture-snapshot-1;model=" + + model + + ";policy=fixture-policy-1;currency=fixture-currency-1"); + } + + private OwnedRunFixture recipe(DataSource source, AtomicReference> current) { + var policy = + new EmbeddedPolicyDecisionAdapter( + List.of((principal, action, resource, context) -> current.get()), true); + return new OwnedRunFixture( + source, + new JdbcTransactionManager(source), + policy, + org.fireflyframework.security.api.domain.SecurityPrincipal.builder() + .subject("owned-fixture-service") + .issuer("owned-fixture-deployment") + .tenantId("owned-a") + .scopes(java.util.Set.of("owned-run:execute")) + .build()); + } + + @Test + void immutableFullIntentClaimAcceptsExactReplayAndRejectsChangedIntent() throws Exception { + DataSource source = prepared(); + var recipe = recipe(source, new AtomicReference<>(Mono.just(Decision.permit()))); + var original = intent("owned-a", "fixture-model-1"); + assertThat(recipe.claim(original)).isEqualTo("CREATED"); + assertThat(recipe.claim(original)).isEqualTo("REPLAY"); + assertThatThrownBy(() -> recipe.claim(intent("owned-a", "changed-model"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("intent"); + var jdbc = new JdbcTemplate(source); + assertThat(jdbc.queryForObject("SELECT canonical FROM owned_run_claim", String.class)) + .isEqualTo(original.canonical()); + assertThat(jdbc.queryForObject("SELECT count(*) FROM owned_run_claim", Integer.class)) + .isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT count(*) FROM owned_run_items", Integer.class)) + .isZero(); + } + + @Test + void differentJobIntentCannotUseAnExistingOwnerClaim() throws Exception { + DataSource source = prepared(); + var recipe = recipe(source, new AtomicReference<>(Mono.just(Decision.permit()))); + recipe.claim(intent("owned-a", "fixture-model-1")); + try (var context = CheckpointJobFixture.open(source)) { + assertThatThrownBy(() -> recipe.job(context, intent("owned-a", "changed-model"), false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("intent"); + } + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM owned_run_items", Integer.class)) + .isZero(); + } + + @Test + void callerRunTenantCannotSelectTheCurrentServiceTenant() throws Exception { + DataSource source = prepared(); + var recipe = recipe(source, new AtomicReference<>(Mono.just(Decision.permit()))); + assertThatThrownBy(() -> recipe.claim(intent("owned-b", "fixture-model-1"))) + .isInstanceOf(IllegalStateException.class); + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM owned_run_claim", Integer.class)) + .isZero(); + } + + @Test + void restartUsesCurrentServicePolicyInsteadOfRetainingEarlierPermit() throws Exception { + DataSource source = prepared(); + var current = new AtomicReference<>(Mono.just(Decision.permit())); + var original = intent("owned-a", "fixture-model-1"); + var recipe = recipe(source, current); + recipe.claim(original); + var parameters = + new JobParametersBuilder() + .addString("tenant", original.tenant(), true) + .addString("run", original.run(), true) + .addString("intent", original.digest(), true) + .toJobParameters(); + long failedId; + try (var first = CheckpointJobFixture.open(source)) { + var repository = first.getBean(JobRepository.class); + var job = recipe.job(first, original, true); + var failed = + CheckpointJobFixture.await( + repository, + first.getBean(JobOperator.class).start(job, parameters).getId()); + failedId = failed.getId(); + assertThat(failed.getStatus()).isEqualTo(BatchStatus.FAILED); + } + assertThat( + new JdbcTemplate(source) + .queryForList( + "SELECT item_no FROM owned_run_items ORDER BY item_no", + Integer.class)) + .containsExactly(1, 2, 3); + current.set(Mono.just(Decision.deny("current fixture policy denied"))); + var restartedRecipe = recipe(source, current); + assertThatThrownBy(() -> restartedRecipe.claim(original)) + .isInstanceOf(IllegalStateException.class); + try (var second = CheckpointJobFixture.open(source)) { + var repository = second.getBean(JobRepository.class); + restartedRecipe.job(second, original, false); + var failed = + CheckpointJobFixture.await( + repository, + second.getBean(JobOperator.class) + .restart(repository.getJobExecution(failedId)) + .getId()); + assertThat(failed.getStatus()).isEqualTo(BatchStatus.FAILED); + } + assertThat( + new JdbcTemplate(source) + .queryForList( + "SELECT item_no FROM owned_run_items ORDER BY item_no", + Integer.class)) + .containsExactly(1, 2, 3); + } + + private static java.util.stream.Stream> invalidCurrentDecisions() { + return java.util.stream.Stream.of( + Mono.empty(), + Mono.error(new IllegalStateException("owned policy failure")), + Mono.just( + Decision.permit(List.of(Obligation.of("fixture-unfulfilled", Map.of()))))); + } + + @ParameterizedTest + @MethodSource("invalidCurrentDecisions") + void emptyFailedAndUnfulfilledObligationDecisionsCannotProduceItems(Mono result) + throws Exception { + DataSource source = prepared(); + var current = new AtomicReference<>(Mono.just(Decision.permit())); + var recipe = recipe(source, current); + var intent = intent("owned-a", "fixture-model-1"); + recipe.claim(intent); + current.set(result); + try (var context = CheckpointJobFixture.open(source)) { + var repository = context.getBean(JobRepository.class); + var job = recipe.job(context, intent, false); + var failed = + CheckpointJobFixture.await( + repository, + context.getBean(JobOperator.class) + .start( + job, + new JobParametersBuilder() + .addString("tenant", intent.tenant(), true) + .addString("run", intent.run(), true) + .addString("intent", intent.digest(), true) + .toJobParameters()) + .getId()); + assertThat(failed.getStatus()).isEqualTo(BatchStatus.FAILED); + } + assertThat( + new JdbcTemplate(source) + .queryForObject( + "SELECT count(*) FROM owned_run_items", Integer.class)) + .isZero(); + } + + @Test + void nativeCompletedMetadataCannotFinalizeOwnerRunWithoutStoredAcknowledgments() + throws Exception { + DataSource source = prepared(); + var recipe = recipe(source, new AtomicReference<>(Mono.just(Decision.permit()))); + var intent = intent("owned-a", "fixture-model-1"); + recipe.claim(intent); + try (var context = CheckpointJobFixture.open(source)) { + var repository = context.getBean(JobRepository.class); + var job = recipe.job(context, intent, false); + var completed = + CheckpointJobFixture.await( + repository, + context.getBean(JobOperator.class) + .start( + job, + new JobParametersBuilder() + .addString("tenant", intent.tenant(), true) + .addString("run", intent.run(), true) + .addString("intent", intent.digest(), true) + .toJobParameters()) + .getId()); + assertThat(completed.getStatus()).isEqualTo(BatchStatus.COMPLETED); + } + assertThat(recipe.completeOnlyWithStoredAcknowledgments(intent)).isFalse(); + assertThat( + new JdbcTemplate(source) + .queryForObject("SELECT state FROM owned_run_claim", String.class)) + .isEqualTo("OPEN"); + } +} diff --git a/modules/cache-hazelcast/README.md b/modules/cache-hazelcast/README.md index 848efe60c..0c8bad17a 100644 --- a/modules/cache-hazelcast/README.md +++ b/modules/cache-hazelcast/README.md @@ -20,7 +20,7 @@ If the onboarding deployment already supplies a Hazelcast instance, this provide org.fireflyframework fireflyframework-cache-hazelcast - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/cache-hazelcast/pom.xml b/modules/cache-hazelcast/pom.xml index b0d4398c3..611f49df0 100644 --- a/modules/cache-hazelcast/pom.xml +++ b/modules/cache-hazelcast/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-cache-hazelcast - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Cache - Hazelcast diff --git a/modules/cache-jcache/README.md b/modules/cache-jcache/README.md index 6ae717abd..c229d8e1d 100644 --- a/modules/cache-jcache/README.md +++ b/modules/cache-jcache/README.md @@ -20,7 +20,7 @@ Use this adapter when the onboarding host supplies a JCache CacheManager. The ap org.fireflyframework fireflyframework-cache-jcache - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/cache-jcache/pom.xml b/modules/cache-jcache/pom.xml index da3e07058..a82317cda 100644 --- a/modules/cache-jcache/pom.xml +++ b/modules/cache-jcache/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-cache-jcache - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Cache - JCache diff --git a/modules/cache-postgres/README.md b/modules/cache-postgres/README.md index c711d5fec..37cfb2991 100644 --- a/modules/cache-postgres/README.md +++ b/modules/cache-postgres/README.md @@ -20,7 +20,7 @@ A service that already operates PostgreSQL can use a separate cache table for re org.fireflyframework fireflyframework-cache-postgres - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/cache-postgres/pom.xml b/modules/cache-postgres/pom.xml index 7fb3d0c24..b098eab24 100644 --- a/modules/cache-postgres/pom.xml +++ b/modules/cache-postgres/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-cache-postgres - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Cache - Postgres diff --git a/modules/cache-redis/README.md b/modules/cache-redis/README.md index 004f412c1..89e1d95ca 100644 --- a/modules/cache-redis/README.md +++ b/modules/cache-redis/README.md @@ -20,7 +20,7 @@ Use a Redis-backed cache when several onboarding-service instances need the same org.fireflyframework fireflyframework-cache-redis - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/cache-redis/pom.xml b/modules/cache-redis/pom.xml index 5cdc91985..89c0bcf3a 100644 --- a/modules/cache-redis/pom.xml +++ b/modules/cache-redis/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-cache-redis - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Cache - Redis diff --git a/modules/cache/README.md b/modules/cache/README.md index 9a8978397..4cfc2fb9a 100644 --- a/modules/cache/README.md +++ b/modules/cache/README.md @@ -20,7 +20,7 @@ A reviewer portal may read the same application status repeatedly. Cache those r org.fireflyframework fireflyframework-cache - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/cache/docs/ARCHITECTURE.md b/modules/cache/docs/ARCHITECTURE.md index 5c2ab2720..58f3fb969 100644 --- a/modules/cache/docs/ARCHITECTURE.md +++ b/modules/cache/docs/ARCHITECTURE.md @@ -137,7 +137,7 @@ For Caffeine only (in-memory cache): org.fireflyframework fireflyframework-cache - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -146,7 +146,7 @@ For Caffeine + Redis (distributed cache): org.fireflyframework fireflyframework-cache - 26.10.21-SNAPSHOT + 26.10.22 org.springframework.boot diff --git a/modules/cache/docs/AUTO_CONFIGURATION.md b/modules/cache/docs/AUTO_CONFIGURATION.md index dd75d5ae6..4591112f4 100644 --- a/modules/cache/docs/AUTO_CONFIGURATION.md +++ b/modules/cache/docs/AUTO_CONFIGURATION.md @@ -290,7 +290,7 @@ Just add the dependency - no configuration needed: org.fireflyframework fireflyframework-cache - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/cache/docs/OPTIONAL_DEPENDENCIES.md b/modules/cache/docs/OPTIONAL_DEPENDENCIES.md index c1c18dccf..2e78d28ec 100644 --- a/modules/cache/docs/OPTIONAL_DEPENDENCIES.md +++ b/modules/cache/docs/OPTIONAL_DEPENDENCIES.md @@ -48,7 +48,7 @@ Maven Dependencies: org.fireflyframework fireflyframework-cache - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -76,7 +76,7 @@ Maven Dependencies: org.fireflyframework fireflyframework-cache - 26.10.21-SNAPSHOT + 26.10.22 diff --git a/modules/cache/pom.xml b/modules/cache/pom.xml index 7d8bfc0ea..923447ac2 100644 --- a/modules/cache/pom.xml +++ b/modules/cache/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml diff --git a/modules/callbacks/README.md b/modules/callbacks/README.md index e261b6f02..6b8f408b3 100644 --- a/modules/callbacks/README.md +++ b/modules/callbacks/README.md @@ -32,7 +32,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-callbacks-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/callbacks/fireflyframework-callbacks-core/README.md b/modules/callbacks/fireflyframework-callbacks-core/README.md index d70c001e5..e8b872759 100644 --- a/modules/callbacks/fireflyframework-callbacks-core/README.md +++ b/modules/callbacks/fireflyframework-callbacks-core/README.md @@ -20,7 +20,7 @@ Route a configured application.submitted or application.reviewed event to author org.fireflyframework fireflyframework-callbacks-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/callbacks/fireflyframework-callbacks-core/pom.xml b/modules/callbacks/fireflyframework-callbacks-core/pom.xml index 520d4baa2..8d0a42fbb 100644 --- a/modules/callbacks/fireflyframework-callbacks-core/pom.xml +++ b/modules/callbacks/fireflyframework-callbacks-core/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-callbacks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/callbacks/fireflyframework-callbacks-interfaces/README.md b/modules/callbacks/fireflyframework-callbacks-interfaces/README.md index fa306b2b4..5268b66ff 100644 --- a/modules/callbacks/fireflyframework-callbacks-interfaces/README.md +++ b/modules/callbacks/fireflyframework-callbacks-interfaces/README.md @@ -20,7 +20,7 @@ Use these DTOs to configure a partner callback for application-status events. Cr org.fireflyframework fireflyframework-callbacks-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/callbacks/fireflyframework-callbacks-interfaces/pom.xml b/modules/callbacks/fireflyframework-callbacks-interfaces/pom.xml index 15daddeae..55d33fe54 100644 --- a/modules/callbacks/fireflyframework-callbacks-interfaces/pom.xml +++ b/modules/callbacks/fireflyframework-callbacks-interfaces/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-callbacks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/callbacks/fireflyframework-callbacks-models/README.md b/modules/callbacks/fireflyframework-callbacks-models/README.md index 097e0846a..72e98ca70 100644 --- a/modules/callbacks/fireflyframework-callbacks-models/README.md +++ b/modules/callbacks/fireflyframework-callbacks-models/README.md @@ -20,7 +20,7 @@ The callback service stores destinations, subscriptions, and executions separate org.fireflyframework fireflyframework-callbacks-models - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/callbacks/fireflyframework-callbacks-models/pom.xml b/modules/callbacks/fireflyframework-callbacks-models/pom.xml index e3fac4dbe..f31278b12 100644 --- a/modules/callbacks/fireflyframework-callbacks-models/pom.xml +++ b/modules/callbacks/fireflyframework-callbacks-models/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-callbacks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/callbacks/fireflyframework-callbacks-sdk/README.md b/modules/callbacks/fireflyframework-callbacks-sdk/README.md index 15211de9d..0793893bc 100644 --- a/modules/callbacks/fireflyframework-callbacks-sdk/README.md +++ b/modules/callbacks/fireflyframework-callbacks-sdk/README.md @@ -20,7 +20,7 @@ A partner-management client can use this generated SDK to register or inspect ca org.fireflyframework fireflyframework-callbacks-sdk - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/callbacks/fireflyframework-callbacks-sdk/pom.xml b/modules/callbacks/fireflyframework-callbacks-sdk/pom.xml index 3b08a72f8..6fb5fca51 100644 --- a/modules/callbacks/fireflyframework-callbacks-sdk/pom.xml +++ b/modules/callbacks/fireflyframework-callbacks-sdk/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-callbacks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/callbacks/fireflyframework-callbacks-web/README.md b/modules/callbacks/fireflyframework-callbacks-web/README.md index 9701b2f9b..8789526b0 100644 --- a/modules/callbacks/fireflyframework-callbacks-web/README.md +++ b/modules/callbacks/fireflyframework-callbacks-web/README.md @@ -20,7 +20,7 @@ Operate this service if onboarding partners manage callbacks through HTTP. Confi org.fireflyframework fireflyframework-callbacks-web - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/callbacks/fireflyframework-callbacks-web/pom.xml b/modules/callbacks/fireflyframework-callbacks-web/pom.xml index a90b6d07c..990fec648 100644 --- a/modules/callbacks/fireflyframework-callbacks-web/pom.xml +++ b/modules/callbacks/fireflyframework-callbacks-web/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-callbacks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/callbacks/pom.xml b/modules/callbacks/pom.xml index 440d81e2f..2c238877c 100644 --- a/modules/callbacks/pom.xml +++ b/modules/callbacks/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-callbacks - 26.10.21-SNAPSHOT + 26.10.22 pom Firefly Framework - Callbacks Library diff --git a/modules/client/README.md b/modules/client/README.md index 82c034952..5de492bb0 100644 --- a/modules/client/README.md +++ b/modules/client/README.md @@ -20,7 +20,7 @@ The service may call a document-verification partner during a later onboarding s org.fireflyframework fireflyframework-client - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/client/examples/complete-example/pom.xml b/modules/client/examples/complete-example/pom.xml index 79eb056e2..b1d6268d9 100644 --- a/modules/client/examples/complete-example/pom.xml +++ b/modules/client/examples/complete-example/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework.example complete-example - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Common Client - Complete Example @@ -21,7 +21,7 @@ - 26.10.21-SNAPSHOT + 26.10.22 25 25 25 diff --git a/modules/client/pom.xml b/modules/client/pom.xml index 8178e5a9a..2e1690eb7 100644 --- a/modules/client/pom.xml +++ b/modules/client/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-client - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Common Client Library diff --git a/modules/config-server/README.md b/modules/config-server/README.md index 637f8c022..6bca95519 100644 --- a/modules/config-server/README.md +++ b/modules/config-server/README.md @@ -20,7 +20,7 @@ When several onboarding services share centrally managed configuration, operate org.fireflyframework fireflyframework-config-server - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/config-server/pom.xml b/modules/config-server/pom.xml index 2feeb139b..fac40ad63 100644 --- a/modules/config-server/pom.xml +++ b/modules/config-server/pom.xml @@ -6,13 +6,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-config-server - 26.10.21-SNAPSHOT + 26.10.22 Firefly Framework - Config Server Spring Cloud Config Server for centralized configuration management across Firefly Framework microservices diff --git a/modules/cqrs/README.md b/modules/cqrs/README.md index 6b35f500a..221efebf8 100644 --- a/modules/cqrs/README.md +++ b/modules/cqrs/README.md @@ -6,6 +6,8 @@ Separate commands and queries with reactive dispatch, execution context, validat Define command/query messages and their handlers, then invoke the appropriate bus. Configure timeouts and query caching through firefly.cqrs; use the EDA integration only when the event transport has also been configured. +Query-result caching requires an explicit `Query.getCacheKey()` implementation; the default returns `null`. A query class name or metadata hash cannot identify all result-affecting inputs. Include the relevant trusted tenant, subject/policy scope, resource identifiers, filters, pagination, and version in a deliberately designed key. Keep credentials and personal data out of keys. Both query-bus overloads honor `isCacheable()` and `@QueryHandlerComponent(cacheable = false)`. Use uncached handlers when a caller needs current authoritative state; supplying an execution context does not add that context to a custom key automatically. + [Module guide](../../docs/site/modules/cqrs.md) · [Jafly README](../../README.md) · [Module catalog](../../docs/site/modules/index.md) ## Installation @@ -16,7 +18,7 @@ Define command/query messages and their handlers, then invoke the appropriate bu org.fireflyframework fireflyframework-cqrs - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/cqrs/docs/EVENT_SOURCING_INTEGRATION.md b/modules/cqrs/docs/EVENT_SOURCING_INTEGRATION.md index cacf2c535..2b6f005bc 100644 --- a/modules/cqrs/docs/EVENT_SOURCING_INTEGRATION.md +++ b/modules/cqrs/docs/EVENT_SOURCING_INTEGRATION.md @@ -103,14 +103,14 @@ CQRS Benefits: org.fireflyframework fireflyframework-cqrs - 26.10.21-SNAPSHOT + 26.10.22 org.fireflyframework fireflyframework-cache - 26.10.21-SNAPSHOT + 26.10.22 diff --git a/modules/cqrs/pom.xml b/modules/cqrs/pom.xml index ac02f3bb2..9ea4d9c81 100644 --- a/modules/cqrs/pom.xml +++ b/modules/cqrs/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-cqrs - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Common CQRS Library diff --git a/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/DefaultQueryBus.java b/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/DefaultQueryBus.java index 82f6b2d1b..39583c172 100644 --- a/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/DefaultQueryBus.java +++ b/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/DefaultQueryBus.java @@ -248,41 +248,42 @@ public Mono query(Query query, ExecutionContext context) { }) .then(Mono.defer(() -> { // Check if caching is enabled and query is cacheable - if (cacheAdapter != null && query.isCacheable() && query.getCacheKey() != null) { + if (cacheAdapter != null && query.isCacheable() && handler.supportsCaching()) { String cacheKey = query.getCacheKey(); - - return cacheAdapter.get(cacheKey, query.getResultType()) - .doOnNext(cachedResult -> log.debug("CQRS Query Cache Hit - Type: {}, ID: {}, CacheKey: {}", - query.getClass().getSimpleName(), query.getQueryId(), cacheKey)) - .doOnSuccess(result -> { - if (result != null) { - log.info("CQRS Query Processing Completed from Cache - Type: {}, ID: {}, Result: {}", - query.getClass().getSimpleName(), query.getQueryId(), - result != null ? "Success" : "Null"); - } - }) - .switchIfEmpty(Mono.defer(() -> { - // Cache miss - execute handler and cache result - log.debug("CQRS Query Cache Miss - Type: {}, ID: {}, CacheKey: {}", - query.getClass().getSimpleName(), query.getQueryId(), cacheKey); - return executeWithMetrics(handler, query, context) - .flatMap(result -> { - if (result != null) { - return cacheAdapter.put(cacheKey, result) - .thenReturn(result) - .doOnSuccess(r -> log.debug("CQRS Query Result Cached - Type: {}, ID: {}, CacheKey: {}", - query.getClass().getSimpleName(), query.getQueryId(), cacheKey)); - } - return Mono.just(result); - }) - .doOnSuccess(result -> log.info("CQRS Query Processing Completed with Context - Type: {}, ID: {}, Result: {}", - query.getClass().getSimpleName(), query.getQueryId(), - result != null ? "Success" : "Null")) - .doOnError(error -> log.error("CQRS Query Processing Failed with Context - Type: {}, ID: {}, Error: {}, Cause: {}", + if (cacheKey != null) { + return cacheAdapter.get(cacheKey, query.getResultType()) + .doOnNext(cachedResult -> log.debug("CQRS Query Cache Hit - Type: {}, ID: {}, CacheKey: {}", + query.getClass().getSimpleName(), query.getQueryId(), cacheKey)) + .doOnSuccess(result -> { + if (result != null) { + log.info("CQRS Query Processing Completed from Cache - Type: {}, ID: {}, Result: {}", query.getClass().getSimpleName(), query.getQueryId(), - error.getClass().getSimpleName(), error.getMessage(), error)); - })) - .doFinally(signalType -> correlationContext.clear()); + result != null ? "Success" : "Null"); + } + }) + .switchIfEmpty(Mono.defer(() -> { + // Cache miss - execute handler and cache result + log.debug("CQRS Query Cache Miss - Type: {}, ID: {}, CacheKey: {}", + query.getClass().getSimpleName(), query.getQueryId(), cacheKey); + return executeWithMetrics(handler, query, context) + .flatMap(result -> { + if (result != null) { + return cacheAdapter.put(cacheKey, result) + .thenReturn(result) + .doOnSuccess(r -> log.debug("CQRS Query Result Cached - Type: {}, ID: {}, CacheKey: {}", + query.getClass().getSimpleName(), query.getQueryId(), cacheKey)); + } + return Mono.just(result); + }) + .doOnSuccess(result -> log.info("CQRS Query Processing Completed with Context - Type: {}, ID: {}, Result: {}", + query.getClass().getSimpleName(), query.getQueryId(), + result != null ? "Success" : "Null")) + .doOnError(error -> log.error("CQRS Query Processing Failed with Context - Type: {}, ID: {}, Error: {}, Cause: {}", + query.getClass().getSimpleName(), query.getQueryId(), + error.getClass().getSimpleName(), error.getMessage(), error)); + })) + .doFinally(signalType -> correlationContext.clear()); + } } // Execute without caching @@ -424,4 +425,4 @@ public QueryProcessingException(String message, Throwable cause) { super(message, cause); } } -} \ No newline at end of file +} diff --git a/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/Query.java b/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/Query.java index ed9d1a8bf..48dd0dbfb 100644 --- a/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/Query.java +++ b/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/Query.java @@ -108,33 +108,22 @@ default boolean isCacheable() { } /** - * Cache key for this query if caching is enabled. - * Default implementation uses query class name and metadata hash. - * The cache key will be automatically prefixed with ":cqrs:" by the QueryCacheAdapter. - * Combined with fireflyframework-cache's "firefly:cache:{cacheName}:" prefix, final keys are: - *
    - *
  • Caffeine: "firefly:cache:default::cqrs:QueryName"
  • - *
  • Redis: "firefly:cache:default::cqrs:QueryName"
  • - *
+ * Cache key for this query, or {@code null} to execute without caching. * - *

The double colon (::) between the cache name and the CQRS namespace provides - * clear visual separation between the cache infrastructure prefix and the application-level namespace. + *

Caching requires an explicit key. A class name or metadata hash cannot + * identify all business inputs or the authority under which a result is read. + * Override this method only when the key includes every result-affecting input + * and the relevant trusted tenant, subject, and policy scope. Do not put raw + * personal data or credentials in a key. Handlers requiring current state + * should disable caching through {@code @QueryHandlerComponent(cacheable = false)}. * - * @return cache key (without prefix), or null to disable caching for this instance + *

The cache adapter adds its CQRS namespace to an explicitly supplied key. + * Existing class-name and metadata-only entries are not read by this default. + * + * @return an explicitly scoped cache key, or null by default */ default String getCacheKey() { - if (!isCacheable()) { - return null; - } - - String baseKey = this.getClass().getSimpleName(); - Map metadata = getMetadata(); - - if (metadata != null && !metadata.isEmpty()) { - return baseKey + ":" + metadata.hashCode(); - } - - return baseKey; + return null; } /** diff --git a/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/QueryHandler.java b/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/QueryHandler.java index 757a81026..f2bfba4d7 100644 --- a/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/QueryHandler.java +++ b/modules/cqrs/src/main/java/org/fireflyframework/cqrs/query/QueryHandler.java @@ -30,7 +30,7 @@ *

This is the only way to create query handlers. It provides: *

    *
  • Zero Boilerplate: Automatic type detection from generics
  • - *
  • Smart Caching: Built-in caching with configurable TTL and automatic key generation
  • + *
  • Query Caching: Optional result caching with explicitly scoped query keys
  • *
  • Performance Monitoring: Automatic timing and success/failure tracking
  • *
  • Extensibility: Pre/post processing hooks for custom logic
  • *
  • Clean API: Just implement doHandle() - everything else is automatic
  • diff --git a/modules/cqrs/src/main/java/org/fireflyframework/cqrs/util/GenericTypeResolver.java b/modules/cqrs/src/main/java/org/fireflyframework/cqrs/util/GenericTypeResolver.java index 9588170fd..7363e8742 100644 --- a/modules/cqrs/src/main/java/org/fireflyframework/cqrs/util/GenericTypeResolver.java +++ b/modules/cqrs/src/main/java/org/fireflyframework/cqrs/util/GenericTypeResolver.java @@ -22,6 +22,10 @@ import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.lang.reflect.TypeVariable; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; /** * Utility class for resolving generic type parameters at runtime. @@ -77,31 +81,10 @@ public static Class resolveGenericType(Class implementationClass, log.debug("Resolving generic type for class: {} extending/implementing: {} at index: {}", implementationClass.getName(), targetClass.getName(), typeParameterIndex); - // Walk up the class hierarchy - Class currentClass = implementationClass; - while (currentClass != null && currentClass != Object.class) { - - // Check direct interfaces - Type[] interfaces = currentClass.getGenericInterfaces(); - for (Type interfaceType : interfaces) { - Class resolvedType = extractTypeFromInterface(interfaceType, targetClass, typeParameterIndex); - if (resolvedType != null) { - log.debug("Resolved generic type: {} for class: {}", resolvedType.getName(), implementationClass.getName()); - return resolvedType; - } - } - - // Check superclass - Type superclass = currentClass.getGenericSuperclass(); - if (superclass instanceof ParameterizedType) { - Class resolvedType = extractTypeFromSuperclass(superclass, targetClass, typeParameterIndex, currentClass); - if (resolvedType != null) { - log.debug("Resolved generic type from superclass: {} for class: {}", resolvedType.getName(), implementationClass.getName()); - return resolvedType; - } - } - - currentClass = currentClass.getSuperclass(); + Class resolvedType = resolveFromHierarchy(implementationClass, targetClass, typeParameterIndex, Map.of()); + if (resolvedType != null) { + log.debug("Resolved generic type: {} for class: {}", resolvedType.getName(), implementationClass.getName()); + return resolvedType; } log.error("Could not resolve generic type for class: {} extending/implementing: {} at index: {}\n{}", @@ -110,77 +93,61 @@ public static Class resolveGenericType(Class implementationClass, return null; } - /** - * Extracts type information from a parameterized interface. - */ @SuppressWarnings("unchecked") - private static Class extractTypeFromInterface(Type interfaceType, - Class targetClass, - int typeParameterIndex) { - if (!(interfaceType instanceof ParameterizedType)) { + private static Class resolveFromHierarchy(Type type, Class targetClass, + int typeParameterIndex, + Map, Type> inheritedBindings) { + Class rawClass; + Map, Type> bindings = new HashMap<>(inheritedBindings); + if (type instanceof ParameterizedType parameterizedType) { + rawClass = (Class) parameterizedType.getRawType(); + TypeVariable[] parameters = rawClass.getTypeParameters(); + Type[] arguments = parameterizedType.getActualTypeArguments(); + for (int i = 0; i < parameters.length; i++) { + bindings.put(parameters[i], resolveBinding(arguments[i], inheritedBindings)); + } + } else if (type instanceof Class typeClass) { + rawClass = typeClass; + } else { return null; } - ParameterizedType parameterizedType = (ParameterizedType) interfaceType; - Type rawType = parameterizedType.getRawType(); - - // Check if this is the target interface - if (rawType.equals(targetClass)) { - Type[] typeArguments = parameterizedType.getActualTypeArguments(); - - if (typeParameterIndex >= typeArguments.length) { - log.warn("Type parameter index {} is out of bounds for interface {} with {} type arguments", - typeParameterIndex, targetClass.getName(), typeArguments.length); + if (rawClass == targetClass) { + TypeVariable[] parameters = rawClass.getTypeParameters(); + if (typeParameterIndex >= parameters.length) { return null; } - - Type typeArgument = typeArguments[typeParameterIndex]; - if (typeArgument instanceof Class) { - return (Class) typeArgument; - } else if (typeArgument instanceof ParameterizedType) { - Type rawTypeArg = ((ParameterizedType) typeArgument).getRawType(); - if (rawTypeArg instanceof Class) { - return (Class) rawTypeArg; - } + Type argument = resolveBinding(parameters[typeParameterIndex], bindings); + if (argument instanceof Class argumentClass) { + return (Class) argumentClass; + } + if (argument instanceof ParameterizedType parameterizedArgument) { + return (Class) parameterizedArgument.getRawType(); } - } - - return null; - } - - /** - * Extracts type information from a parameterized superclass. - */ - @SuppressWarnings("unchecked") - private static Class extractTypeFromSuperclass(Type superclass, - Class targetClass, - int typeParameterIndex, - Class currentClass) { - if (!(superclass instanceof ParameterizedType)) { return null; } - ParameterizedType parameterizedSuperclass = (ParameterizedType) superclass; - Class superclassRaw = (Class) parameterizedSuperclass.getRawType(); - - // Check if this superclass IS the target class (for abstract classes like CommandHandler) - if (superclassRaw.equals(targetClass)) { - Type[] typeArguments = parameterizedSuperclass.getActualTypeArguments(); - if (typeArguments.length > typeParameterIndex) { - Type typeArg = typeArguments[typeParameterIndex]; - if (typeArg instanceof Class) { - return (Class) typeArg; - } else if (typeArg instanceof ParameterizedType) { - Type rawTypeArg = ((ParameterizedType) typeArg).getRawType(); - if (rawTypeArg instanceof Class) { - return (Class) rawTypeArg; - } - } + for (Type interfaceType : rawClass.getGenericInterfaces()) { + Class resolvedType = resolveFromHierarchy(interfaceType, targetClass, typeParameterIndex, bindings); + if (resolvedType != null) { + return resolvedType; } } + Type superclass = rawClass.getGenericSuperclass(); + return superclass != null ? resolveFromHierarchy(superclass, targetClass, typeParameterIndex, bindings) : null; + } - // Recursively check if the superclass implements the target interface (for interfaces) - return resolveGenericType(superclassRaw, targetClass, typeParameterIndex); + private static Type resolveBinding(Type type, Map, Type> bindings) { + Set visited = new HashSet<>(); + while (type instanceof TypeVariable && visited.add(type)) { + Type binding = bindings.get(type); + if (binding == null) { + break; + } + type = binding; + } + // Unbound variables stay unresolved rather than becoming their upper bounds. + return type; } /** diff --git a/modules/cqrs/src/test/java/org/fireflyframework/cqrs/integration/QueryCacheIsolationTest.java b/modules/cqrs/src/test/java/org/fireflyframework/cqrs/integration/QueryCacheIsolationTest.java new file mode 100644 index 000000000..f622ce9c8 --- /dev/null +++ b/modules/cqrs/src/test/java/org/fireflyframework/cqrs/integration/QueryCacheIsolationTest.java @@ -0,0 +1,123 @@ +package org.fireflyframework.cqrs.integration; + +import org.fireflyframework.cache.config.CacheAutoConfiguration; +import org.fireflyframework.cqrs.annotations.QueryHandlerComponent; +import org.fireflyframework.cqrs.cache.QueryCacheAdapter; +import org.fireflyframework.cqrs.config.CqrsAutoConfiguration; +import org.fireflyframework.cqrs.context.ExecutionContext; +import org.fireflyframework.cqrs.query.DefaultQueryBus; +import org.fireflyframework.cqrs.query.Query; +import org.fireflyframework.cqrs.query.QueryHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = {CacheAutoConfiguration.class, CqrsAutoConfiguration.class, + QueryCacheIsolationTest.TestConfig.class}) +@TestPropertySource(properties = {"firefly.cache.enabled=true", "firefly.cache.default-cache-type=CAFFEINE"}) +class QueryCacheIsolationTest { + @Configuration + static class TestConfig { } + + @Autowired DefaultQueryBus bus; + @Autowired QueryCacheAdapter cache; + private UnkeyedHandler unkeyed; + private KeyedHandler keyed; + private FreshHandler fresh; + + @BeforeEach + void reset() { + cache.clear().block(); + unkeyed = new UnkeyedHandler(); + keyed = new KeyedHandler(); + fresh = new FreshHandler(); + bus.registerHandler(unkeyed); + bus.registerHandler(keyed); + bus.registerHandler(fresh); + } + + @Test + void defaultKeyDoesNotGuessTheCompleteQueryIdentity() { + assertThat(new Unkeyed("request-a").getCacheKey()).isNull(); + Query metadataOnly = new Query<>() { + @Override public Map getMetadata() { return Map.of("page", 1); } + }; + assertThat(metadataOnly.getCacheKey()).isNull(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void differentInputsNeverShareAnImplicitClassKey(boolean contextual) { + assertThat(read(new Unkeyed("request-a"), contextual)).isEqualTo("request-a"); + assertThat(read(new Unkeyed("request-b"), contextual)).isEqualTo("request-b"); + assertThat(read(new Unkeyed("request-a"), contextual)).isEqualTo("request-a"); + assertThat(unkeyed.calls).hasValue(3); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void explicitlyScopedKeysStillCacheAndIsolateInputs(boolean contextual) { + assertThat(read(new Keyed("request-a"), contextual)).isEqualTo("request-a"); + assertThat(read(new Keyed("request-b"), contextual)).isEqualTo("request-b"); + assertThat(read(new Keyed("request-a"), contextual)).isEqualTo("request-a"); + assertThat(keyed.calls).hasValue(2); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void authoritativeHandlerDisablesBothCacheReadsAndWrites(boolean contextual) { + cache.put("fresh:request-a", "stale").block(); + assertThat(read(new Fresh("request-a"), contextual)).isEqualTo("request-a:1"); + assertThat(read(new Fresh("request-a"), contextual)).isEqualTo("request-a:2"); + assertThat(cache.get("fresh:request-a", String.class).block()).isEqualTo("stale"); + } + + private String read(Query query, boolean contextual) { + return (contextual ? bus.query(query, ExecutionContext.builder().withTenantId("fixture-tenant").build()) + : bus.query(query)).block(); + } + + record Unkeyed(String requestId) implements Query { } + record Keyed(String requestId) implements Query { + @Override public String getCacheKey() { return "fixture-tenant:keyed:" + requestId; } + @Override public Class getResultType() { return String.class; } + } + record Fresh(String requestId) implements Query { + @Override public String getCacheKey() { return "fresh:" + requestId; } + @Override public Class getResultType() { return String.class; } + } + @QueryHandlerComponent + static class UnkeyedHandler extends QueryHandler { + final AtomicInteger calls = new AtomicInteger(); + @Override protected Mono doHandle(Unkeyed query) { + calls.incrementAndGet(); + return Mono.just(query.requestId()); + } + } + @QueryHandlerComponent + static class KeyedHandler extends QueryHandler { + final AtomicInteger calls = new AtomicInteger(); + @Override protected Mono doHandle(Keyed query) { + calls.incrementAndGet(); + return Mono.just(query.requestId()); + } + } + @QueryHandlerComponent(cacheable = false) + static class FreshHandler extends QueryHandler { + @Override protected Mono doHandle(Fresh query) { + return Mono.just(query.requestId() + ":" + calls.incrementAndGet()); + } + final AtomicInteger calls = new AtomicInteger(); + } +} diff --git a/modules/cqrs/src/test/java/org/fireflyframework/cqrs/util/GenericTypeResolverTest.java b/modules/cqrs/src/test/java/org/fireflyframework/cqrs/util/GenericTypeResolverTest.java new file mode 100644 index 000000000..c11d4febd --- /dev/null +++ b/modules/cqrs/src/test/java/org/fireflyframework/cqrs/util/GenericTypeResolverTest.java @@ -0,0 +1,133 @@ +package org.fireflyframework.cqrs.util; + +import org.fireflyframework.cqrs.command.Command; +import org.fireflyframework.cqrs.command.CommandHandler; +import org.fireflyframework.cqrs.command.ContextAwareCommandHandler; +import org.fireflyframework.cqrs.context.ExecutionContext; +import org.fireflyframework.cqrs.query.ContextAwareQueryHandler; +import org.fireflyframework.cqrs.query.Query; +import org.fireflyframework.cqrs.query.QueryHandler; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GenericTypeResolverTest { + @Test + void constructsContextAwareCommandHandlerWithConcreteRequestAndResultTypes() { + var handler = new ContextCommand(); + assertThat(handler.getCommandType()).isEqualTo(TestCommand.class); + assertThat(handler.getResultType()).isEqualTo(String.class); + } + + @Test + void constructsContextAwareQueryHandlerWithConcreteRequestAndResultTypes() { + var handler = new ContextQuery(); + assertThat(handler.getQueryType()).isEqualTo(TestQuery.class); + assertThat(handler.getResultType()).isEqualTo(String.class); + } + + @Test + void carriesReorderedCommandBindingsAcrossMultipleSuperclasses() { + var handler = new InheritedCommand(); + assertThat(handler.getCommandType()).isEqualTo(TestCommand.class); + assertThat(handler.getResultType()).isEqualTo(String.class); + } + + @Test + void carriesReorderedQueryBindingsAcrossMultipleSuperclasses() { + var handler = new InheritedQuery(); + assertThat(handler.getQueryType()).isEqualTo(TestQuery.class); + assertThat(handler.getResultType()).isEqualTo(String.class); + } + + @Test + void resolvesInheritedInterfaceBindingsWithoutAssumingParameterOrder() { + assertThat(GenericTypeResolver.resolveGenericType(InterfaceImplementation.class, Pair.class, 0)).isEqualTo(String.class); + assertThat(GenericTypeResolver.resolveGenericType(InterfaceImplementation.class, Pair.class, 1)).isEqualTo(Integer.class); + } + + @Test + void retainsRawClassOfResolvedParameterizedArguments() { + assertThat(GenericTypeResolver.resolveGenericType(ListImplementation.class, Pair.class, 0)).isEqualTo(List.class); + assertThat(GenericTypeResolver.resolveGenericType(ListImplementation.class, Pair.class, 1)).isEqualTo(String.class); + } + + @Test + void unresolvedCommandVariableDoesNotFallBackToItsCommandBound() { + assertThat(GenericTypeResolver.resolveGenericType(UnresolvedCommand.class, CommandHandler.class, 0)).isNull(); + assertThatThrownBy(() -> GenericTypeResolver.resolveCommandType(UnresolvedCommand.class)) + .isInstanceOf(GenericTypeResolver.TypeResolutionException.class); + } + + @Test + void unresolvedQueryVariableDoesNotFallBackToItsQueryBound() { + assertThat(GenericTypeResolver.resolveQueryType(UnresolvedQuery.class)).isNull(); + } + + @Test + void rawContextAwareHandlersRemainUnresolved() { + assertThat(GenericTypeResolver.resolveGenericType(ContextAwareCommandHandler.class, CommandHandler.class, 0)).isNull(); + assertThat(GenericTypeResolver.resolveGenericType(ContextAwareCommandHandler.class, CommandHandler.class, 1)).isNull(); + assertThat(GenericTypeResolver.resolveQueryType(ContextAwareQueryHandler.class)).isNull(); + assertThat(GenericTypeResolver.resolveQueryResultType(ContextAwareQueryHandler.class)).isNull(); + } + + @Test + void rawAndUnresolvedInterfacesRemainUnresolved() { + assertThat(GenericTypeResolver.resolveGenericType(RawImplementation.class, Pair.class, 0)).isNull(); + assertThat(GenericTypeResolver.resolveGenericType(UnresolvedPair.class, Pair.class, 0)).isNull(); + } + + @Test + void explicitlyBoundObjectRemainsAValidResultType() { + assertThat(GenericTypeResolver.resolveGenericType(ObjectImplementation.class, Pair.class, 0)).isEqualTo(Object.class); + } + + @Test + void unrelatedTargetAndOutOfRangeIndexRemainUnresolved() { + assertThat(GenericTypeResolver.resolveGenericType(String.class, Pair.class, 0)).isNull(); + assertThat(GenericTypeResolver.resolveGenericType(InterfaceImplementation.class, Pair.class, 2)).isNull(); + } + + @Test + void rejectsNullInputsAndNegativeIndices() { + assertThatThrownBy(() -> GenericTypeResolver.resolveGenericType(null, Pair.class, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GenericTypeResolver.resolveGenericType(InterfaceImplementation.class, null, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GenericTypeResolver.resolveGenericType(InterfaceImplementation.class, Pair.class, -1)).isInstanceOf(IllegalArgumentException.class); + } + + record TestCommand() implements Command { } + record TestQuery() implements Query { } + + static class ContextCommand extends ContextAwareCommandHandler { + @Override protected Mono doHandle(TestCommand command, ExecutionContext context) { return Mono.just("command"); } + } + static class ContextQuery extends ContextAwareQueryHandler { + @Override protected Mono doHandle(TestQuery query, ExecutionContext context) { return Mono.just("query"); } + } + abstract static class ReorderedCommand> extends ContextAwareCommandHandler { } + static class BoundCommand extends ReorderedCommand { + @Override protected Mono doHandle(TestCommand command, ExecutionContext context) { return Mono.just("command"); } + } + static class InheritedCommand extends BoundCommand { } + abstract static class ReorderedQuery> extends ContextAwareQueryHandler { } + static class BoundQuery extends ReorderedQuery { + @Override protected Mono doHandle(TestQuery query, ExecutionContext context) { return Mono.just("query"); } + } + static class InheritedQuery extends BoundQuery { } + abstract static class UnresolvedCommand> extends ContextAwareCommandHandler { } + abstract static class UnresolvedQuery> extends ContextAwareQueryHandler { } + interface Pair { } + interface ReorderedPair extends Pair { } + abstract static class PairBase implements ReorderedPair { } + static class InterfaceImplementation extends PairBase { } + static class ListImplementation extends PairBase, String> { } + static class ObjectImplementation extends PairBase { } + static class UnresolvedPair extends PairBase { } + @SuppressWarnings("rawtypes") + static class RawImplementation implements Pair { } +} diff --git a/modules/ecm-esignature-adobe-sign/README.md b/modules/ecm-esignature-adobe-sign/README.md index bc6edef24..c46535f02 100644 --- a/modules/ecm-esignature-adobe-sign/README.md +++ b/modules/ecm-esignature-adobe-sign/README.md @@ -20,7 +20,7 @@ If onboarding includes a signed agreement, use the Adobe Sign adapter for the co org.fireflyframework fireflyframework-ecm-esignature-adobe-sign - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm-esignature-adobe-sign/pom.xml b/modules/ecm-esignature-adobe-sign/pom.xml index 13c3ab453..637fa9847 100644 --- a/modules/ecm-esignature-adobe-sign/pom.xml +++ b/modules/ecm-esignature-adobe-sign/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-ecm-esignature-adobe-sign - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - ECM eSignature - Adobe Sign diff --git a/modules/ecm-esignature-docusign/README.md b/modules/ecm-esignature-docusign/README.md index 4abcf611c..1125de16b 100644 --- a/modules/ecm-esignature-docusign/README.md +++ b/modules/ecm-esignature-docusign/README.md @@ -20,7 +20,7 @@ If onboarding includes a signed agreement, use the DocuSign adapter for the conf org.fireflyframework fireflyframework-ecm-esignature-docusign - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm-esignature-docusign/pom.xml b/modules/ecm-esignature-docusign/pom.xml index 7361cd589..9c658e0ca 100644 --- a/modules/ecm-esignature-docusign/pom.xml +++ b/modules/ecm-esignature-docusign/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-ecm-esignature-docusign - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - ECM eSignature - DocuSign diff --git a/modules/ecm-esignature-logalty/README.md b/modules/ecm-esignature-logalty/README.md index 736987434..7ae106c6e 100644 --- a/modules/ecm-esignature-logalty/README.md +++ b/modules/ecm-esignature-logalty/README.md @@ -20,7 +20,7 @@ If onboarding includes a signed agreement, use the Logalty adapter for the confi org.fireflyframework fireflyframework-ecm-esignature-logalty - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm-esignature-logalty/pom.xml b/modules/ecm-esignature-logalty/pom.xml index bd3dda9b5..20ad06fca 100644 --- a/modules/ecm-esignature-logalty/pom.xml +++ b/modules/ecm-esignature-logalty/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-ecm-esignature-logalty - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - ECM eSignature - Logalty diff --git a/modules/ecm-storage-aws/README.md b/modules/ecm-storage-aws/README.md index 554ee5649..7fe7bafb2 100644 --- a/modules/ecm-storage-aws/README.md +++ b/modules/ecm-storage-aws/README.md @@ -20,7 +20,7 @@ Store an applicant's supporting document in Amazon S3 through the ECM content po org.fireflyframework fireflyframework-ecm-storage-aws - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm-storage-aws/pom.xml b/modules/ecm-storage-aws/pom.xml index 15494d8be..8c0559d5a 100644 --- a/modules/ecm-storage-aws/pom.xml +++ b/modules/ecm-storage-aws/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-ecm-storage-aws - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - ECM Storage - AWS (S3) diff --git a/modules/ecm-storage-azure/README.md b/modules/ecm-storage-azure/README.md index ccb390f4f..31cdf441f 100644 --- a/modules/ecm-storage-azure/README.md +++ b/modules/ecm-storage-azure/README.md @@ -20,7 +20,7 @@ Store an applicant's supporting document in Azure Blob Storage through the ECM c org.fireflyframework fireflyframework-ecm-storage-azure - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm-storage-azure/pom.xml b/modules/ecm-storage-azure/pom.xml index a3345a9dc..60ca6e109 100644 --- a/modules/ecm-storage-azure/pom.xml +++ b/modules/ecm-storage-azure/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-ecm-storage-azure - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - ECM Storage - Azure Blob diff --git a/modules/ecm/README.md b/modules/ecm/README.md index 5aa0e0fec..ab12b9c54 100644 --- a/modules/ecm/README.md +++ b/modules/ecm/README.md @@ -20,7 +20,7 @@ An applicant may upload supporting documents or sign an agreement after submissi org.fireflyframework fireflyframework-ecm - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm/docs/adapters/docusign-integration-guide.md b/modules/ecm/docs/adapters/docusign-integration-guide.md index f642266a3..435ace9e8 100644 --- a/modules/ecm/docs/adapters/docusign-integration-guide.md +++ b/modules/ecm/docs/adapters/docusign-integration-guide.md @@ -60,14 +60,14 @@ Add the DocuSign adapter dependency to your `pom.xml`: org.fireflyframework fireflyframework-ecm - 26.10.21-SNAPSHOT + 26.10.22 org.fireflyframework fireflyframework-ecm-esignature-docusign - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm/docs/adapters/s3-integration-guide.md b/modules/ecm/docs/adapters/s3-integration-guide.md index d726ba146..6b6b2eb24 100644 --- a/modules/ecm/docs/adapters/s3-integration-guide.md +++ b/modules/ecm/docs/adapters/s3-integration-guide.md @@ -26,14 +26,14 @@ Add the S3 adapter dependency to your `pom.xml`: org.fireflyframework fireflyframework-ecm - 26.10.21-SNAPSHOT + 26.10.22 org.fireflyframework fireflyframework-ecm-storage-aws - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm/docs/configuration-reference.md b/modules/ecm/docs/configuration-reference.md index 64493c679..93f7c05ae 100644 --- a/modules/ecm/docs/configuration-reference.md +++ b/modules/ecm/docs/configuration-reference.md @@ -57,7 +57,7 @@ Requires dependency: `fireflyframework-ecm-storage-aws` org.fireflyframework fireflyframework-ecm-storage-aws - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -134,7 +134,7 @@ Requires dependency: `fireflyframework-ecm-esignature-docusign` org.fireflyframework fireflyframework-ecm-esignature-docusign - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -163,7 +163,7 @@ Requires dependency: `fireflyframework-ecm-esignature-adobe-sign` org.fireflyframework fireflyframework-ecm-esignature-adobe-sign - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm/docs/configuration.md b/modules/ecm/docs/configuration.md index f76f1895c..3d18cf2c3 100644 --- a/modules/ecm/docs/configuration.md +++ b/modules/ecm/docs/configuration.md @@ -18,14 +18,14 @@ Before configuring, ensure you have added the required dependencies: org.fireflyframework fireflyframework-ecm - 26.10.21-SNAPSHOT + 26.10.22 org.fireflyframework fireflyframework-ecm-storage-aws - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/ecm/docs/guides/azure-integration.md b/modules/ecm/docs/guides/azure-integration.md index addb6ea72..522d38ea9 100644 --- a/modules/ecm/docs/guides/azure-integration.md +++ b/modules/ecm/docs/guides/azure-integration.md @@ -51,7 +51,7 @@ Add the required dependencies to your `pom.xml`: org.fireflyframework fireflyframework-ecm - 26.10.21-SNAPSHOT + 26.10.22 diff --git a/modules/ecm/pom.xml b/modules/ecm/pom.xml index fbcda69f6..5d5f03410 100644 --- a/modules/ecm/pom.xml +++ b/modules/ecm/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-ecm - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Enterprise Content Management Library diff --git a/modules/eda-kafka/README.md b/modules/eda-kafka/README.md index 76661ecd2..6ceea297f 100644 --- a/modules/eda-kafka/README.md +++ b/modules/eda-kafka/README.md @@ -20,7 +20,7 @@ Publish application lifecycle events to Kafka when a separate review worker cons org.fireflyframework fireflyframework-eda-kafka - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/eda-kafka/pom.xml b/modules/eda-kafka/pom.xml index 6b6916c77..6346f8687 100644 --- a/modules/eda-kafka/pom.xml +++ b/modules/eda-kafka/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-eda-kafka - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - EDA - Kafka diff --git a/modules/eda-postgres/README.md b/modules/eda-postgres/README.md index 4de7254cc..d66b20799 100644 --- a/modules/eda-postgres/README.md +++ b/modules/eda-postgres/README.md @@ -20,7 +20,7 @@ A PostgreSQL delivery table can carry application lifecycle events without a sep org.fireflyframework fireflyframework-eda-postgres - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/eda-postgres/pom.xml b/modules/eda-postgres/pom.xml index bf6c6be7f..7da2066f2 100644 --- a/modules/eda-postgres/pom.xml +++ b/modules/eda-postgres/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-eda-postgres - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - EDA - PostgreSQL diff --git a/modules/eda-rabbitmq/README.md b/modules/eda-rabbitmq/README.md index 8c760548b..ae5f34017 100644 --- a/modules/eda-rabbitmq/README.md +++ b/modules/eda-rabbitmq/README.md @@ -20,7 +20,7 @@ RabbitMQ can route application lifecycle events to the configured review queues. org.fireflyframework fireflyframework-eda-rabbitmq - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/eda-rabbitmq/pom.xml b/modules/eda-rabbitmq/pom.xml index 1313da966..b43b446c2 100644 --- a/modules/eda-rabbitmq/pom.xml +++ b/modules/eda-rabbitmq/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-eda-rabbitmq - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - EDA - RabbitMQ diff --git a/modules/eda/README.md b/modules/eda/README.md index 8c2fb910d..25846acc8 100644 --- a/modules/eda/README.md +++ b/modules/eda/README.md @@ -20,7 +20,7 @@ Once a submitted application must notify another service, define an application. org.fireflyframework fireflyframework-eda - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/eda/pom.xml b/modules/eda/pom.xml index a87bef045..98b7fd332 100644 --- a/modules/eda/pom.xml +++ b/modules/eda/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml diff --git a/modules/eventsourcing/README.md b/modules/eventsourcing/README.md index 735b6b648..953401aa7 100644 --- a/modules/eventsourcing/README.md +++ b/modules/eventsourcing/README.md @@ -20,7 +20,7 @@ If the product needs application history as the source of truth, model submitted org.fireflyframework fireflyframework-eventsourcing - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/eventsourcing/docs/quick-start.md b/modules/eventsourcing/docs/quick-start.md index 75cc98a86..944f6c118 100644 --- a/modules/eventsourcing/docs/quick-start.md +++ b/modules/eventsourcing/docs/quick-start.md @@ -15,7 +15,7 @@ Use event history to reconstruct an aggregate. In an onboarding service, that wo org.fireflyframework fireflyframework-eventsourcing - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/eventsourcing/pom.xml b/modules/eventsourcing/pom.xml index 546e2049a..935a79fad 100644 --- a/modules/eventsourcing/pom.xml +++ b/modules/eventsourcing/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-eventsourcing - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Common Event Sourcing Library diff --git a/modules/kernel/README.md b/modules/kernel/README.md index 24171ff56..7258d67b1 100644 --- a/modules/kernel/README.md +++ b/modules/kernel/README.md @@ -20,7 +20,7 @@ A rejected application and an unavailable verification partner need different er org.fireflyframework fireflyframework-kernel - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/kernel/pom.xml b/modules/kernel/pom.xml index 2af3736c1..e0eea36fe 100644 --- a/modules/kernel/pom.xml +++ b/modules/kernel/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-kernel - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Kernel diff --git a/modules/notifications-firebase/README.md b/modules/notifications-firebase/README.md index b7c2657e5..bdd85e1a3 100644 --- a/modules/notifications-firebase/README.md +++ b/modules/notifications-firebase/README.md @@ -20,7 +20,7 @@ A mobile applicant portal can receive push notifications through Firebase. The a org.fireflyframework fireflyframework-notifications-firebase - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/notifications-firebase/pom.xml b/modules/notifications-firebase/pom.xml index 27b3d5e3f..da676b5bf 100644 --- a/modules/notifications-firebase/pom.xml +++ b/modules/notifications-firebase/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-notifications-firebase - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Notifications - Firebase diff --git a/modules/notifications-resend/README.md b/modules/notifications-resend/README.md index 35b48c4ed..77db1e4d9 100644 --- a/modules/notifications-resend/README.md +++ b/modules/notifications-resend/README.md @@ -20,7 +20,7 @@ Resend is another email provider for application confirmations. Select it throug org.fireflyframework fireflyframework-notifications-resend - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/notifications-resend/pom.xml b/modules/notifications-resend/pom.xml index 966774916..4b0898bee 100644 --- a/modules/notifications-resend/pom.xml +++ b/modules/notifications-resend/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-notifications-resend - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Notifications - Resend diff --git a/modules/notifications-sendgrid/README.md b/modules/notifications-sendgrid/README.md index 571d1f897..668d89e8b 100644 --- a/modules/notifications-sendgrid/README.md +++ b/modules/notifications-sendgrid/README.md @@ -20,7 +20,7 @@ SendGrid can deliver application confirmations through the email provider port. org.fireflyframework fireflyframework-notifications-sendgrid - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/notifications-sendgrid/pom.xml b/modules/notifications-sendgrid/pom.xml index 5f0bbdae6..55bb90b4c 100644 --- a/modules/notifications-sendgrid/pom.xml +++ b/modules/notifications-sendgrid/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-notifications-sendgrid - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Notifications - SendGrid diff --git a/modules/notifications-twilio/README.md b/modules/notifications-twilio/README.md index 8242ead32..0facb0665 100644 --- a/modules/notifications-twilio/README.md +++ b/modules/notifications-twilio/README.md @@ -20,7 +20,7 @@ Use the Twilio SMS provider for an onboarding message when the product has colle org.fireflyframework fireflyframework-notifications-twilio - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/notifications-twilio/pom.xml b/modules/notifications-twilio/pom.xml index 57abf1c65..ac4b191d3 100644 --- a/modules/notifications-twilio/pom.xml +++ b/modules/notifications-twilio/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-notifications-twilio - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Notifications - Twilio diff --git a/modules/notifications/README.md b/modules/notifications/README.md index 90e3b3ff5..db12840bd 100644 --- a/modules/notifications/README.md +++ b/modules/notifications/README.md @@ -28,7 +28,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-notifications-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/notifications/fireflyframework-notifications-core/README.md b/modules/notifications/fireflyframework-notifications-core/README.md index 7aa98c850..2748c52d4 100644 --- a/modules/notifications/fireflyframework-notifications-core/README.md +++ b/modules/notifications/fireflyframework-notifications-core/README.md @@ -20,7 +20,7 @@ The onboarding service can use EmailService to confirm receipt or request missin org.fireflyframework fireflyframework-notifications-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/notifications/fireflyframework-notifications-core/pom.xml b/modules/notifications/fireflyframework-notifications-core/pom.xml index bbd4bc9aa..a67425434 100644 --- a/modules/notifications/fireflyframework-notifications-core/pom.xml +++ b/modules/notifications/fireflyframework-notifications-core/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-notifications - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/notifications/pom.xml b/modules/notifications/pom.xml index e96a34e8f..ee3170981 100644 --- a/modules/notifications/pom.xml +++ b/modules/notifications/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-notifications - 26.10.21-SNAPSHOT + 26.10.22 pom Firefly Framework - Notifications Library diff --git a/modules/observability/README.md b/modules/observability/README.md index 7cfa99184..1dc78eeb8 100644 --- a/modules/observability/README.md +++ b/modules/observability/README.md @@ -20,7 +20,7 @@ Measure application submissions and review latency with the metrics support, and org.fireflyframework fireflyframework-observability - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/observability/pom.xml b/modules/observability/pom.xml index 0b10e432b..95e4d4040 100644 --- a/modules/observability/pom.xml +++ b/modules/observability/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-observability - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Observability diff --git a/modules/orchestration/README.md b/modules/orchestration/README.md index 7593cfd1e..1459f67b0 100644 --- a/modules/orchestration/README.md +++ b/modules/orchestration/README.md @@ -20,7 +20,7 @@ An extended onboarding flow might verify documents, wait for a review, and provi org.fireflyframework fireflyframework-orchestration - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -119,6 +119,23 @@ tccEngine.execute(def, TccInputs.of(Map.of("debit", request, "credit", request)) }); ``` +### Require TCC persistence acknowledgments + +`BEST_EFFORT` is the default. Select `REQUIRED` when an execution must stop if its configured persistence provider does not acknowledge a save: + +```yaml +firefly: + orchestration: + tcc: + persistence-mode: REQUIRED +``` + +The native configuration gives the engine and orchestrator the same policy/provider. Manual assembly must do so explicitly; REQUIRED rejects absent or mismatched providers/policies. An initial save failure invokes no participant. A required checkpoint or final-save failure propagates `TccPersistenceException` with its correlation ID, save stage, attempted status, original cause, possible-effects flag and retained execution context. It cannot become a confirmed result, an error-suppressed success or an automatic compensation request. + +Overall completion observations, execution-completed publication and success callbacks—including asynchronous callback subscription—wait for the final acknowledgment. Participant/phase events still describe calls that already occurred and do not establish overall financial completion. Preserve the original operation/resource IDs when handling uncertainty; do not blindly retry execution or compensate based only on this error. + +An acknowledged save means the configured publisher completed. The default in-memory provider does not gain disk durability through this setting. Existing phase snapshots are not a participant journal or a durable pause/resume protocol: a CONFIRMED phase checkpoint can exist without an acknowledged final report. Actual provider storage, restart recovery and the product's financial action protocol need separate qualification. Existing participant-error cancellation rules remain; define participant timeout/uncertain-outcome semantics before using the example for money movement. + ### Workflow ```java diff --git a/modules/orchestration/docs/configuration.md b/modules/orchestration/docs/configuration.md index 17b0cefcf..db21a4e7d 100644 --- a/modules/orchestration/docs/configuration.md +++ b/modules/orchestration/docs/configuration.md @@ -22,6 +22,12 @@ All properties are prefixed with `firefly.orchestration`. | `firefly.orchestration.tcc.enabled` | `boolean` | `true` | Enable TCC pattern | | `firefly.orchestration.tcc.default-timeout` | `Duration` | `30s` | Default TCC timeout | +### TCC Persistence Acknowledgment + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `firefly.orchestration.tcc.persistence-mode` | `TccPersistenceMode` | `BEST_EFFORT` | `REQUIRED` propagates failed save acknowledgments and gates overall completion; see [TCC acknowledgment limits](tcc.md#required-persistence-acknowledgment) | + ### Persistence Properties | Property | Type | Default | Description | diff --git a/modules/orchestration/docs/foundations.md b/modules/orchestration/docs/foundations.md index db74ded48..eda2cefd1 100644 --- a/modules/orchestration/docs/foundations.md +++ b/modules/orchestration/docs/foundations.md @@ -43,7 +43,7 @@ Each pattern addresses a different class of distributed coordination problem: org.fireflyframework fireflyframework-orchestration - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/orchestration/docs/tcc.md b/modules/orchestration/docs/tcc.md index 4f0f2ed34..6bb5eb4a1 100644 --- a/modules/orchestration/docs/tcc.md +++ b/modules/orchestration/docs/tcc.md @@ -10,6 +10,7 @@ Contents: - [§25 TccEngine API](#25-tccengine-api) - [§26 TccResult](#26-tccresult) - [§27 TCC Composition](#27-tcc-composition) +- [Required persistence acknowledgment](#required-persistence-acknowledgment) ## 21. TCC Annotation Reference @@ -1020,3 +1021,27 @@ String mermaid = viz.toMermaid(composition); [← Back to Index](README.md) | [Previous: Saga Pattern](saga.md) | [Next: Core Infrastructure →](core-infrastructure.md) *Copyright 2024-2026 Firefly Software Foundation. Licensed under the Apache License, Version 2.0.* + + +## Required persistence acknowledgment + +TCC persistence defaults to `BEST_EFFORT`. Save failures are logged and execution continues. Existing engine and orchestrator constructors retain this default. + +To require the configured provider to acknowledge every existing TCC save: + +```yaml +firefly: + orchestration: + tcc: + persistence-mode: REQUIRED +``` + +`REQUIRED` waits for the initial save before invoking any participant. It also waits for the existing `CONFIRMING`, post-confirm `CONFIRMED`, and `CANCELING` checkpoints, plus the final state/report save. Overall successful completion events and `@OnTccComplete` callbacks, including asynchronous callbacks, follow the final acknowledgment. Participant events and phase observations still describe individual calls and phases; they are not proof of overall transaction completion. + +A failed required save terminates the publisher with `TccPersistenceException`. Its message is sanitized; the original provider cause is available through `getCause()`. The exception exposes the correlation ID, save stage (`INITIAL`, `CHECKPOINT`, or `FINAL`), attempted status, `hasPossibleParticipantEffects()`, and the retained `getExecutionContext()`. The flag is false for an initial save failure and conservatively true for later failures. Use the cause and execution context in trusted diagnostics and operator-directed recovery. + +A persistence failure does not automatically cancel participants, invoke the generic engine error callback path, suppress the error into `CONFIRMED`, or overwrite the last saved state with `FAILED`. A provider may have applied a write before failing to acknowledge it. Participants may also have completed effects before a later checkpoint fails. Do not treat this exception as permission to retry the transaction or compensate automatically. Genuine participant failures keep the existing TCC cancellation and callback behavior. + +For manual construction, pass `TccPersistenceMode.REQUIRED` to the new engine and orchestrator constructor overloads and use the same non-null provider instance for both. A missing provider or inconsistent policy fails at construction. Spring Boot passes the configured policy to both default beans; a custom orchestrator must use the same policy as the configured engine. + +Acknowledgment means that the selected provider's save publisher completed. It does not establish disk durability; the default in-memory provider remains in-memory. The existing checkpoints are phase snapshots, not a per-participant journal. For example, a retained `CONFIRMED` checkpoint without the final report may precede a failed final acknowledgment. This option does not add durable pause/resume, complete automated recovery, distributed atomicity, or financial exactly-once guarantees. diff --git a/modules/orchestration/docs/tutorial.md b/modules/orchestration/docs/tutorial.md index 4f5e53349..fc3b79c15 100644 --- a/modules/orchestration/docs/tutorial.md +++ b/modules/orchestration/docs/tutorial.md @@ -70,7 +70,7 @@ Customer initiates transfer: $500 from Account A → Account B org.fireflyframework fireflyframework-orchestration - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/orchestration/pom.xml b/modules/orchestration/pom.xml index b6fd9b61b..27aa08cbc 100644 --- a/modules/orchestration/pom.xml +++ b/modules/orchestration/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/OrchestrationProperties.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/OrchestrationProperties.java index 96439bdb8..900c6c845 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/OrchestrationProperties.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/OrchestrationProperties.java @@ -16,6 +16,8 @@ package org.fireflyframework.orchestration.config; +import org.fireflyframework.orchestration.tcc.engine.TccPersistenceMode; + import org.fireflyframework.orchestration.core.model.CompensationPolicy; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; @@ -202,6 +204,7 @@ public static class SagaProperties { public static class TccProperties { private boolean enabled = true; + private TccPersistenceMode persistenceMode = TccPersistenceMode.BEST_EFFORT; private Duration defaultTimeout = Duration.ofSeconds(30); @NestedConfigurationProperty @@ -213,6 +216,9 @@ public static class TccProperties { public Duration getDefaultTimeout() { return defaultTimeout; } public void setDefaultTimeout(Duration defaultTimeout) { this.defaultTimeout = defaultTimeout; } + public TccPersistenceMode getPersistenceMode() { return persistenceMode; } + public void setPersistenceMode(TccPersistenceMode persistenceMode) { this.persistenceMode = persistenceMode; } + public TccCompositionProperties getComposition() { return composition; } public void setComposition(TccCompositionProperties composition) { this.composition = composition; } } diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/TccAutoConfiguration.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/TccAutoConfiguration.java index f97c0b8b9..5192840f5 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/TccAutoConfiguration.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/TccAutoConfiguration.java @@ -64,8 +64,10 @@ public TccRegistry tccRegistry(ApplicationContext applicationContext, public TccExecutionOrchestrator tccExecutionOrchestrator(StepInvoker stepInvoker, OrchestrationEvents events, OrchestrationEventPublisher eventPublisher, - ExecutionPersistenceProvider persistence) { - return new TccExecutionOrchestrator(stepInvoker, events, eventPublisher, persistence); + ExecutionPersistenceProvider persistence, + OrchestrationProperties properties) { + return new TccExecutionOrchestrator(stepInvoker, events, eventPublisher, persistence, + properties.getTcc().getPersistenceMode()); } @Bean @@ -76,11 +78,12 @@ public TccEngine tccEngine(TccRegistry registry, ExecutionPersistenceProvider persistence, ObjectProvider dlqService, OrchestrationEventPublisher eventPublisher, - ObjectProvider tracer) { + ObjectProvider tracer, + OrchestrationProperties properties) { log.info("[orchestration] TCC engine initialized"); return new TccEngine(registry, events, orchestrator, persistence, dlqService.getIfAvailable(), eventPublisher, - tracer.getIfAvailable()); + tracer.getIfAvailable(), properties.getTcc().getPersistenceMode()); } // --- TCC Composition beans --- diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/exception/OrchestrationException.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/exception/OrchestrationException.java index 89953b1d2..092b37e00 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/exception/OrchestrationException.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/exception/OrchestrationException.java @@ -22,7 +22,7 @@ public sealed class OrchestrationException extends FireflyException permits ExecutionNotFoundException, StepExecutionException, TopologyValidationException, DuplicateDefinitionException, - ExecutionTimeoutException, CompensationException, TccPhaseException { + ExecutionTimeoutException, CompensationException, TccPhaseException, TccPersistenceException { public OrchestrationException(String message) { super(message); diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/exception/TccPersistenceException.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/exception/TccPersistenceException.java new file mode 100644 index 000000000..ea19c2ca6 --- /dev/null +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/exception/TccPersistenceException.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.orchestration.core.exception; + +import org.fireflyframework.orchestration.core.context.ExecutionContext; +import org.fireflyframework.orchestration.core.model.ExecutionStatus; + +import java.util.Map; + +/** + * A required save was not acknowledged. Participant effects may already exist; + * this error is not a request to retry execution or automatically compensate. + */ +public final class TccPersistenceException extends OrchestrationException { + public enum Stage { INITIAL, CHECKPOINT, FINAL } + + private final Stage stage; + private final ExecutionStatus attemptedStatus; + private final ExecutionContext executionContext; + private final boolean possibleParticipantEffects; + + public TccPersistenceException(Stage stage, ExecutionStatus attemptedStatus, + ExecutionContext executionContext, Throwable cause) { + super("Required TCC persistence acknowledgment failed", "ORCHESTRATION_TCC_PERSISTENCE_ERROR", + Map.of("correlationId", executionContext.getCorrelationId(), "stage", stage.name(), + "attemptedStatus", attemptedStatus.name(), + "possibleParticipantEffects", stage != Stage.INITIAL), cause); + this.stage = stage; + this.attemptedStatus = attemptedStatus; + this.executionContext = executionContext; + this.possibleParticipantEffects = stage != Stage.INITIAL; + } + + public Stage getStage() { return stage; } + public ExecutionStatus getAttemptedStatus() { return attemptedStatus; } + public String getCorrelationId() { return executionContext.getCorrelationId(); } + public boolean hasPossibleParticipantEffects() { return possibleParticipantEffects; } + public ExecutionContext getExecutionContext() { return executionContext; } +} diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/composition/TccCompositor.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/composition/TccCompositor.java index 4a6b569c3..51c6b10d9 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/composition/TccCompositor.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/composition/TccCompositor.java @@ -32,8 +32,9 @@ * *

    The compositor delegates individual TCC execution to {@link TccEngine}, which * handles Try-Confirm-Cancel phases internally. {@link TccEngine#execute(String, Map)} - * always returns a {@link TccResult} (never errors) — even failures are represented - * as {@link TccResult.Status#FAILED} or {@link TccResult.Status#CANCELED}. + * represents participant failures as {@link TccResult.Status#FAILED} or + * {@link TccResult.Status#CANCELED}. Required persistence acknowledgment failures + * propagate as errors without entering composition compensation. */ @Slf4j public class TccCompositor { diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccEngine.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccEngine.java index 38902bcad..57dcd2e1c 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccEngine.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccEngine.java @@ -17,6 +17,7 @@ package org.fireflyframework.orchestration.tcc.engine; import org.fireflyframework.orchestration.core.context.ExecutionContext; +import org.fireflyframework.orchestration.core.exception.TccPersistenceException; import org.fireflyframework.orchestration.core.dlq.DeadLetterEntry; import org.fireflyframework.orchestration.core.dlq.DeadLetterService; import org.fireflyframework.orchestration.core.event.OrchestrationEvent; @@ -57,6 +58,7 @@ public class TccEngine { private final DeadLetterService dlqService; private final OrchestrationEventPublisher eventPublisher; private final OrchestrationTracer tracer; + private final TccPersistenceMode persistenceMode; public TccEngine(TccRegistry registry, OrchestrationEvents events, TccExecutionOrchestrator orchestrator, @@ -69,6 +71,24 @@ public TccEngine(TccRegistry registry, OrchestrationEvents events, TccExecutionOrchestrator orchestrator, ExecutionPersistenceProvider persistence, DeadLetterService dlqService, OrchestrationEventPublisher eventPublisher, OrchestrationTracer tracer) { + this(registry, events, orchestrator, persistence, dlqService, eventPublisher, tracer, + TccPersistenceMode.BEST_EFFORT); + } + + public TccEngine(TccRegistry registry, OrchestrationEvents events, + TccExecutionOrchestrator orchestrator, + ExecutionPersistenceProvider persistence, DeadLetterService dlqService, + OrchestrationEventPublisher eventPublisher, OrchestrationTracer tracer, + TccPersistenceMode persistenceMode) { + this.persistenceMode = Objects.requireNonNull(persistenceMode, "persistenceMode"); + Objects.requireNonNull(orchestrator, "orchestrator"); + if (persistenceMode != orchestrator.getPersistenceMode()) { + throw new IllegalArgumentException("TCC engine and orchestrator persistence modes must match"); + } + if (persistenceMode == TccPersistenceMode.REQUIRED + && (persistence == null || persistence != orchestrator.getPersistenceProvider())) { + throw new IllegalArgumentException("REQUIRED TCC persistence needs the same provider for engine and orchestrator"); + } this.registry = registry; this.events = events; this.orchestrator = orchestrator; @@ -105,19 +125,20 @@ public Mono execute(TccDefinition tcc, TccInputs inputs, ExecutionCon Map inputMap = inputs != null ? inputs.asMap() : Map.of(); Mono execution = - orchestrator.orchestrate(tcc, inputs, finalCtx); + Mono.defer(() -> orchestrator.orchestrate(tcc, inputs, finalCtx)); if (tracer != null) { execution = tracer.traceExecution(tcc.name, ExecutionPattern.TCC, finalCtx.getCorrelationId(), execution); } return persistInitialState(tcc, finalCtx) - .then(eventPublisher.publish(OrchestrationEvent.executionStarted( - tcc.name, finalCtx.getCorrelationId(), ExecutionPattern.TCC))) + .then(Mono.defer(() -> eventPublisher.publish(OrchestrationEvent.executionStarted( + tcc.name, finalCtx.getCorrelationId(), ExecutionPattern.TCC)))) .then(Mono.fromRunnable(() -> events.onStart(tcc.name, finalCtx.getCorrelationId(), ExecutionPattern.TCC))) .then(execution) .flatMap(result -> handleResult(result, tcc, inputMap)) .onErrorResume(err -> { + if (err instanceof TccPersistenceException) return Mono.error(err); log.error("[tcc] Unexpected error executing TCC '{}': {}", tcc.name, err.getMessage(), err); long durationMs = Duration.between(finalCtx.getStartedAt(), Instant.now()).toMillis(); events.onCompleted(tcc.name, finalCtx.getCorrelationId(), ExecutionPattern.TCC, false, durationMs); @@ -128,8 +149,8 @@ public Mono execute(TccDefinition tcc, TccInputs inputs, ExecutionCon TccResult failedResult = TccResult.failed(tcc.name, finalCtx, null, null, err, Map.of()); return persistFinalState(finalCtx, ExecutionStatus.FAILED, err.getMessage(), failedReport) - .then(eventPublisher.publish(OrchestrationEvent.executionCompleted( - tcc.name, finalCtx.getCorrelationId(), ExecutionPattern.TCC, ExecutionStatus.FAILED))) + .then(Mono.defer(() -> eventPublisher.publish(OrchestrationEvent.executionCompleted( + tcc.name, finalCtx.getCorrelationId(), ExecutionPattern.TCC, ExecutionStatus.FAILED)))) .thenReturn(failedResult.withReport(failedReport)); })); }); @@ -139,8 +160,6 @@ private Mono handleResult(TccExecutionOrchestrator.OrchestratorResult TccDefinition tcc, Map inputMap) { ExecutionContext ctx = result.getContext(); long durationMs = Duration.between(ctx.getStartedAt(), Instant.now()).toMillis(); - boolean success = result.getStatus() == TccResult.Status.CONFIRMED; - events.onCompleted(tcc.name, ctx.getCorrelationId(), ExecutionPattern.TCC, success, durationMs); TccResult tccResult; ExecutionStatus finalStatus; @@ -167,7 +186,7 @@ private Mono handleResult(TccExecutionOrchestrator.OrchestratorResult Mono errorCallbacks = Mono.empty(); if (finalStatus == ExecutionStatus.FAILED) { - errorCallbacks = invokeTccErrorCallbacks(tcc, ctx, result.getFailureError()); + errorCallbacks = Mono.defer(() -> invokeTccErrorCallbacks(tcc, ctx, result.getFailureError())); } // For FAILED status, defer persist/publish until after suppress check @@ -179,15 +198,19 @@ private Mono handleResult(TccExecutionOrchestrator.OrchestratorResult TccResult suppressed = TccResult.confirmed(tcc.name, ctx, result.getParticipantOutcomes()); ExecutionReport suppressedReport = ExecutionReportBuilder.fromContext(ctx, ExecutionStatus.CONFIRMED, null); return persistFinalState(ctx, ExecutionStatus.CONFIRMED, null, suppressedReport) - .then(eventPublisher.publish(OrchestrationEvent.executionCompleted( - tcc.name, ctx.getCorrelationId(), ExecutionPattern.TCC, ExecutionStatus.CONFIRMED))) + .then(Mono.fromRunnable(() -> events.onCompleted(tcc.name, ctx.getCorrelationId(), + ExecutionPattern.TCC, true, durationMs))) + .then(Mono.defer(() -> eventPublisher.publish(OrchestrationEvent.executionCompleted( + tcc.name, ctx.getCorrelationId(), ExecutionPattern.TCC, ExecutionStatus.CONFIRMED)))) .thenReturn(suppressed.withReport(suppressedReport)); } String failureReason = result.getFailureError() != null ? result.getFailureError().getMessage() : null; ExecutionReport failedReport = ExecutionReportBuilder.fromContext(ctx, ExecutionStatus.FAILED, failureReason); return persistFinalState(ctx, ExecutionStatus.FAILED, failureReason, failedReport) - .then(eventPublisher.publish(OrchestrationEvent.executionCompleted( - tcc.name, ctx.getCorrelationId(), ExecutionPattern.TCC, ExecutionStatus.FAILED))) + .then(Mono.fromRunnable(() -> events.onCompleted(tcc.name, ctx.getCorrelationId(), + ExecutionPattern.TCC, false, durationMs))) + .then(Mono.defer(() -> eventPublisher.publish(OrchestrationEvent.executionCompleted( + tcc.name, ctx.getCorrelationId(), ExecutionPattern.TCC, ExecutionStatus.FAILED)))) .thenReturn(failedTccResult.withReport(failedReport)); })); } @@ -199,13 +222,16 @@ private Mono handleResult(TccExecutionOrchestrator.OrchestratorResult Mono callbacks = Mono.empty(); if (finalStatus == ExecutionStatus.CONFIRMED) { - callbacks = invokeTccCompleteCallbacks(tcc, ctx, tccResult); + final TccResult callbackResult = tccResult; + callbacks = Mono.defer(() -> invokeTccCompleteCallbacks(tcc, ctx, callbackResult)); } final TccResult finalTccResult = tccResult; return persistFinalState(ctx, finalStatus, failureReason, report) - .then(eventPublisher.publish(OrchestrationEvent.executionCompleted( - tcc.name, ctx.getCorrelationId(), ExecutionPattern.TCC, finalStatus))) + .then(Mono.fromRunnable(() -> events.onCompleted(tcc.name, ctx.getCorrelationId(), + ExecutionPattern.TCC, finalStatus == ExecutionStatus.CONFIRMED, durationMs))) + .then(Mono.defer(() -> eventPublisher.publish(OrchestrationEvent.executionCompleted( + tcc.name, ctx.getCorrelationId(), ExecutionPattern.TCC, finalStatus)))) .then(callbacks) .thenReturn(finalTccResult); } @@ -232,11 +258,8 @@ private Mono persistInitialState(TccDefinition tcc, ExecutionContext ctx) ExecutionStatus.TRYING, Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Set.of(), List.of(), null, ctx.getStartedAt(), Instant.now(), Optional.empty()); - return persistence.save(state) - .onErrorResume(err -> { - log.warn("[tcc] Failed to persist initial state: {}", ctx.getCorrelationId(), err); - return Mono.empty(); - }); + return TccPersistenceSupport.save(persistence, persistenceMode, ctx, state, + TccPersistenceException.Stage.INITIAL); } private Mono persistFinalState(ExecutionContext ctx, ExecutionStatus status, @@ -250,11 +273,8 @@ private Mono persistFinalState(ExecutionContext ctx, ExecutionStatus statu new HashMap<>(ctx.getHeaders()), Set.copyOf(ctx.getIdempotencyKeys()), ctx.getTopologyLayers(), failureReason, ctx.getStartedAt(), Instant.now(), Optional.ofNullable(report)); - return persistence.save(state) - .onErrorResume(err -> { - log.warn("[tcc] Failed to persist final state: {}", ctx.getCorrelationId(), err); - return Mono.empty(); - }); + return TccPersistenceSupport.save(persistence, persistenceMode, ctx, state, + TccPersistenceException.Stage.FINAL); } private Mono invokeTccCompleteCallbacks(TccDefinition tcc, ExecutionContext ctx, TccResult tccResult) { diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccExecutionOrchestrator.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccExecutionOrchestrator.java index 16d65c8ee..0bf417319 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccExecutionOrchestrator.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccExecutionOrchestrator.java @@ -19,6 +19,7 @@ import org.fireflyframework.orchestration.core.context.ExecutionContext; import org.fireflyframework.orchestration.core.context.TccPhase; import org.fireflyframework.orchestration.core.event.OrchestrationEvent; +import org.fireflyframework.orchestration.core.exception.TccPersistenceException; import org.fireflyframework.orchestration.core.event.OrchestrationEventPublisher; import org.fireflyframework.orchestration.core.model.ExecutionPattern; import org.fireflyframework.orchestration.core.model.ExecutionStatus; @@ -54,6 +55,7 @@ public class TccExecutionOrchestrator { private final OrchestrationEvents events; private final OrchestrationEventPublisher eventPublisher; private final ExecutionPersistenceProvider persistence; + private final TccPersistenceMode persistenceMode; public TccExecutionOrchestrator(StepInvoker stepInvoker, OrchestrationEvents events, OrchestrationEventPublisher eventPublisher) { @@ -63,13 +65,31 @@ public TccExecutionOrchestrator(StepInvoker stepInvoker, OrchestrationEvents eve public TccExecutionOrchestrator(StepInvoker stepInvoker, OrchestrationEvents events, OrchestrationEventPublisher eventPublisher, ExecutionPersistenceProvider persistence) { + this(stepInvoker, events, eventPublisher, persistence, TccPersistenceMode.BEST_EFFORT); + } + + public TccExecutionOrchestrator(StepInvoker stepInvoker, OrchestrationEvents events, + OrchestrationEventPublisher eventPublisher, + ExecutionPersistenceProvider persistence, TccPersistenceMode persistenceMode) { + this.persistenceMode = Objects.requireNonNull(persistenceMode, "persistenceMode"); + if (persistenceMode == TccPersistenceMode.REQUIRED && persistence == null) { + throw new IllegalArgumentException("REQUIRED TCC persistence needs a provider"); + } this.stepInvoker = Objects.requireNonNull(stepInvoker, "stepInvoker"); this.events = Objects.requireNonNull(events, "events"); this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher"); this.persistence = persistence; } + public TccPersistenceMode getPersistenceMode() { return persistenceMode; } + + ExecutionPersistenceProvider getPersistenceProvider() { return persistence; } + public Mono orchestrate(TccDefinition tccDef, TccInputs inputs, ExecutionContext ctx) { + return Mono.defer(() -> orchestrateDeferred(tccDef, inputs, ctx)); + } + + private Mono orchestrateDeferred(TccDefinition tccDef, TccInputs inputs, ExecutionContext ctx) { events.onStart(tccDef.name, ctx.getCorrelationId(), ExecutionPattern.TCC); List ordered = getOrderedParticipants(tccDef); OrchestratorState state = new OrchestratorState(tccDef, ordered, inputs, ctx); @@ -78,7 +98,7 @@ public Mono orchestrate(TccDefinition tccDef, TccInputs inpu .flatMap(trySuccess -> { if (trySuccess) { return saveCheckpoint(state, ExecutionStatus.CONFIRMING) - .then(executeConfirmPhase(state)); + .then(Mono.defer(() -> executeConfirmPhase(state))); } else { return executeCancelPhase(state); } @@ -342,11 +362,8 @@ private Mono saveCheckpoint(OrchestratorState state, ExecutionStatus statu Set.copyOf(ctx.getIdempotencyKeys()), List.of(), null, ctx.getStartedAt(), Instant.now(), Optional.empty()); - return persistence.save(checkpoint) - .onErrorResume(err -> { - log.warn("[tcc] Failed to save TCC checkpoint: {}", ctx.getCorrelationId(), err); - return Mono.empty(); - }); + return TccPersistenceSupport.save(persistence, persistenceMode, ctx, checkpoint, + TccPersistenceException.Stage.CHECKPOINT); } // --- Internal State --- diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccPersistenceMode.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccPersistenceMode.java new file mode 100644 index 000000000..c4289c994 --- /dev/null +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccPersistenceMode.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.orchestration.tcc.engine; + +/** The acknowledgment required from the configured TCC persistence provider. */ +public enum TccPersistenceMode { + BEST_EFFORT, + REQUIRED +} diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccPersistenceSupport.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccPersistenceSupport.java new file mode 100644 index 000000000..e10d7279d --- /dev/null +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/tcc/engine/TccPersistenceSupport.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.orchestration.tcc.engine; + +import lombok.extern.slf4j.Slf4j; +import org.fireflyframework.orchestration.core.context.ExecutionContext; +import org.fireflyframework.orchestration.core.exception.TccPersistenceException; +import org.fireflyframework.orchestration.core.persistence.ExecutionPersistenceProvider; +import org.fireflyframework.orchestration.core.persistence.ExecutionState; +import reactor.core.publisher.Mono; + +@Slf4j +final class TccPersistenceSupport { + private TccPersistenceSupport() {} + + static Mono save(ExecutionPersistenceProvider provider, TccPersistenceMode mode, + ExecutionContext context, ExecutionState state, TccPersistenceException.Stage stage) { + if (provider == null) return Mono.empty(); + return Mono.defer(() -> provider.save(state)) + .onErrorResume(error -> { + if (mode == TccPersistenceMode.REQUIRED) { + return Mono.error(new TccPersistenceException(stage, state.status(), context, error)); + } + log.warn("[tcc] Failed to persist {} state: {}", stage, context.getCorrelationId(), error); + return Mono.empty(); + }); + } +} diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/TccPersistenceModeBootTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/TccPersistenceModeBootTest.java new file mode 100644 index 000000000..84c306294 --- /dev/null +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/TccPersistenceModeBootTest.java @@ -0,0 +1,168 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.orchestration.config; + +import org.fireflyframework.orchestration.core.argument.ArgumentResolver; +import org.fireflyframework.orchestration.core.event.NoOpEventPublisher; +import org.fireflyframework.orchestration.core.exception.TccPersistenceException; +import org.fireflyframework.orchestration.core.model.ExecutionStatus; +import org.fireflyframework.orchestration.core.observability.OrchestrationEvents; +import org.fireflyframework.orchestration.core.persistence.ExecutionState; +import org.fireflyframework.orchestration.core.persistence.InMemoryPersistenceProvider; +import org.fireflyframework.orchestration.core.step.StepInvoker; +import org.fireflyframework.orchestration.tcc.builder.TccBuilder; +import org.fireflyframework.orchestration.tcc.engine.TccEngine; +import org.fireflyframework.orchestration.tcc.engine.TccExecutionOrchestrator; +import org.fireflyframework.orchestration.tcc.engine.TccInputs; +import org.fireflyframework.orchestration.tcc.engine.TccPersistenceMode; +import org.fireflyframework.orchestration.tcc.registry.TccDefinition; +import org.junit.jupiter.api.Test; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.*; + +class TccPersistenceModeBootTest { + @Test + void configuredRequiredModeRetainsAcknowledgedCheckpointsAndFinalReport() { + var provider = new RetainingProvider(false); + try (var context = application(provider).properties( + "firefly.orchestration.tcc.persistence-mode=required").run()) { + assertThat(context.getBean(OrchestrationProperties.class).getTcc().getPersistenceMode()) + .isEqualTo(TccPersistenceMode.REQUIRED); + assertThat(context.getBean(TccExecutionOrchestrator.class).getPersistenceMode()) + .isEqualTo(TccPersistenceMode.REQUIRED); + var counts = new Counts(); + StepVerifier.create(context.getBean(TccEngine.class).execute(definition(counts), TccInputs.empty())) + .assertNext(result -> { + assertThat(result.isConfirmed()).isTrue(); + assertThat(provider.accepted).extracting(ExecutionState::status).containsExactly( + ExecutionStatus.TRYING, ExecutionStatus.CONFIRMING, + ExecutionStatus.CONFIRMED, ExecutionStatus.CONFIRMED); + assertThat(provider.accepted).allMatch(state -> + state.correlationId().equals(result.correlationId())); + assertThat(provider.accepted.get(1).stepResults()).containsEntry("reservation", "reserved"); + assertThat(provider.accepted.get(2).report()).isEmpty(); + assertThat(provider.findById(result.correlationId()).block().orElseThrow().report()) + .isPresent(); + }).verifyComplete(); + assertThat(counts.tries).hasValue(1); + assertThat(counts.confirms).hasValue(1); + assertThat(counts.cancels).hasValue(0); + } + } + + @Test + void configuredRequiredFinalFailureLeavesLastAcknowledgedSnapshotAndSignalsUncertainty() { + var provider = new RetainingProvider(true); + try (var context = application(provider).properties( + "firefly.orchestration.tcc.persistence-mode=REQUIRED").run()) { + var counts = new Counts(); + StepVerifier.create(context.getBean(TccEngine.class).execute(definition(counts), TccInputs.empty())) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(TccPersistenceException.class); + var failure = (TccPersistenceException) error; + assertThat(failure.getStage()).isEqualTo(TccPersistenceException.Stage.FINAL); + assertThat(failure.hasPossibleParticipantEffects()).isTrue(); + assertThat(failure.getCause()).isSameAs(provider.cause); + var retained = provider.findById(failure.getCorrelationId()).block().orElseThrow(); + assertThat(retained.status()).isEqualTo(ExecutionStatus.CONFIRMED); + assertThat(retained.report()).isEmpty(); + assertThat(retained.stepResults()).containsEntry("reservation", "reserved"); + assertThat(provider.accepted).hasSize(3); + assertThat(provider.attempted).hasSize(4); + }).verify(); + assertThat(counts.tries).hasValue(1); + assertThat(counts.confirms).hasValue(1); + assertThat(counts.cancels).hasValue(0); + } + } + + @Test + void defaultBootPropertyUsesBestEffortAndCanReturnConfirmedWithoutFinalAcknowledgment() { + var provider = new RetainingProvider(true); + try (var context = application(provider).run()) { + assertThat(context.getBean(OrchestrationProperties.class).getTcc().getPersistenceMode()) + .isEqualTo(TccPersistenceMode.BEST_EFFORT); + StepVerifier.create(context.getBean(TccEngine.class).execute(definition(new Counts()), TccInputs.empty())) + .assertNext(result -> assertThat(result.isConfirmed()).isTrue()).verifyComplete(); + assertThat(provider.attempted).hasSize(4); + assertThat(provider.accepted).hasSize(3); + } + } + + @Test + void requiredBootPropertyRejectsCustomBestEffortOrchestrator() { + var provider = new RetainingProvider(false); + var custom = new TccExecutionOrchestrator(new StepInvoker(new ArgumentResolver()), + new OrchestrationEvents() {}, new NoOpEventPublisher(), provider); + assertThatThrownBy(() -> application(provider).initializers(context -> + context.getBeanFactory().registerSingleton("customTccOrchestrator", custom)) + .properties("firefly.orchestration.tcc.persistence-mode=REQUIRED").run()) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasStackTraceContaining("persistence modes must match"); + assertThat(provider.attempted).isEmpty(); + } + + @Test + void invalidBootPersistenceModeFailsInsteadOfFallingBack() { + assertThatThrownBy(() -> application(new RetainingProvider(false)).properties( + "firefly.orchestration.tcc.persistence-mode=UNRECOGNIZED").run()) + .hasStackTraceContaining("UNRECOGNIZED"); + } + + private SpringApplicationBuilder application(RetainingProvider provider) { + return new SpringApplicationBuilder(OrchestrationAutoConfiguration.class, TccAutoConfiguration.class) + .web(WebApplicationType.NONE) + .initializers(context -> context.getBeanFactory().registerSingleton("retainedTccProvider", provider)) + .properties("spring.main.banner-mode=off", "logging.level.root=ERROR", + "firefly.orchestration.tcc.composition.enabled=false"); + } + + private TccDefinition definition(Counts counts) { + return TccBuilder.tccNoRetry("boot-required-persistence").participant("reservation") + .tryHandler((input, ctx) -> { counts.tries.incrementAndGet(); return Mono.just("reserved"); }) + .confirmHandler((input, ctx) -> { counts.confirms.incrementAndGet(); return Mono.just("confirmed"); }) + .cancelHandler((input, ctx) -> { counts.cancels.incrementAndGet(); return Mono.just("canceled"); }) + .add().build(); + } + + static class Counts { + final AtomicInteger tries = new AtomicInteger(); + final AtomicInteger confirms = new AtomicInteger(); + final AtomicInteger cancels = new AtomicInteger(); + } + + static class RetainingProvider extends InMemoryPersistenceProvider { + final List attempted = new ArrayList<>(); + final List accepted = new ArrayList<>(); + final IllegalStateException cause = new IllegalStateException("private-provider-detail"); + private final boolean failFinal; + RetainingProvider(boolean failFinal) { this.failFinal = failFinal; } + @Override public Mono save(ExecutionState state) { + attempted.add(state); + if (failFinal && state.report().isPresent()) return Mono.error(cause); + return super.save(state).doOnSuccess(ignored -> accepted.add(state)); + } + } +} diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/TccPersistenceModeConfigurationTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/TccPersistenceModeConfigurationTest.java new file mode 100644 index 000000000..f5acaf2f4 --- /dev/null +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/TccPersistenceModeConfigurationTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.orchestration.config; + +import org.fireflyframework.orchestration.core.persistence.ExecutionPersistenceProvider; +import org.fireflyframework.orchestration.core.persistence.ExecutionState; +import org.fireflyframework.orchestration.core.persistence.InMemoryPersistenceProvider; +import org.fireflyframework.orchestration.tcc.builder.TccBuilder; +import org.fireflyframework.orchestration.tcc.engine.TccEngine; +import org.fireflyframework.orchestration.tcc.engine.TccInputs; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class TccPersistenceModeConfigurationTest { + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OrchestrationAutoConfiguration.class, + TccAutoConfiguration.class)) + .withPropertyValues("firefly.orchestration.tcc.composition.enabled=false"); + + @Test + void requiredPropertyStopsBeforeAnyParticipantWhenInitialSaveFails() { + var calls = new AtomicInteger(); + var cause = new IllegalStateException("private-provider-detail"); + var provider = new InMemoryPersistenceProvider() { + @Override public Mono save(ExecutionState state) { return Mono.error(cause); } + }; + runner.withBean(ExecutionPersistenceProvider.class, () -> provider) + .withPropertyValues("firefly.orchestration.tcc.persistence-mode=REQUIRED") + .run(context -> { + assertThat(context).hasNotFailed(); + var tcc = TccBuilder.tccNoRetry("configured-required") + .participant("reservation") + .tryHandler((input, ctx) -> { calls.incrementAndGet(); return Mono.just("reserved"); }) + .confirmHandler((input, ctx) -> Mono.just("confirmed")) + .cancelHandler((input, ctx) -> Mono.just("canceled")).add().build(); + StepVerifier.create(context.getBean(TccEngine.class).execute(tcc, TccInputs.empty())) + .expectErrorSatisfies(error -> { + assertThat(error.getClass().getSimpleName()).isEqualTo("TccPersistenceException"); + assertThat(error.getCause()).isSameAs(cause); + assertThat(error.getMessage()).doesNotContain("private-provider-detail"); + }).verify(); + assertThat(calls).hasValue(0); + }); + } + + @Test + void omittedPropertyRetainsBestEffortWithUnavailableProvider() { + runner.withBean(ExecutionPersistenceProvider.class, () -> new InMemoryPersistenceProvider() { + @Override public Mono save(ExecutionState state) { + return Mono.error(new IllegalStateException("unavailable")); + } + }) + .run(context -> { + assertThat(context).hasNotFailed(); + var tcc = TccBuilder.tccNoRetry("configured-default") + .participant("reservation") + .tryHandler((input, ctx) -> Mono.just("reserved")) + .confirmHandler((input, ctx) -> Mono.just("confirmed")) + .cancelHandler((input, ctx) -> Mono.just("canceled")).add().build(); + StepVerifier.create(context.getBean(TccEngine.class).execute(tcc, TccInputs.empty())) + .assertNext(result -> assertThat(result.isConfirmed()).isTrue()).verifyComplete(); + }); + } +} diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/unit/tcc/TccRequiredPersistenceTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/unit/tcc/TccRequiredPersistenceTest.java new file mode 100644 index 000000000..b6445c6c0 --- /dev/null +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/unit/tcc/TccRequiredPersistenceTest.java @@ -0,0 +1,369 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.orchestration.unit.tcc; + +import org.fireflyframework.orchestration.core.argument.ArgumentResolver; +import org.fireflyframework.orchestration.core.context.ExecutionContext; +import org.fireflyframework.orchestration.core.event.NoOpEventPublisher; +import org.fireflyframework.orchestration.core.event.OrchestrationEvent; +import org.fireflyframework.orchestration.core.event.OrchestrationEventPublisher; +import org.fireflyframework.orchestration.core.exception.TccPersistenceException; +import org.fireflyframework.orchestration.core.model.ExecutionPattern; +import org.fireflyframework.orchestration.core.model.ExecutionStatus; +import org.fireflyframework.orchestration.core.observability.OrchestrationEvents; +import org.fireflyframework.orchestration.core.persistence.ExecutionState; +import org.fireflyframework.orchestration.core.persistence.InMemoryPersistenceProvider; +import org.fireflyframework.orchestration.core.step.StepInvoker; +import org.fireflyframework.orchestration.tcc.annotation.OnTccComplete; +import org.fireflyframework.orchestration.tcc.annotation.OnTccError; +import org.fireflyframework.orchestration.tcc.builder.TccBuilder; +import org.fireflyframework.orchestration.tcc.engine.*; +import org.fireflyframework.orchestration.tcc.registry.TccDefinition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.*; + +class TccRequiredPersistenceTest { + private final StepInvoker invoker = new StepInvoker(new ArgumentResolver()); + private final List published = new CopyOnWriteArrayList<>(); + // Deliberately eager: even construction of publish must wait for acknowledgment. + private final OrchestrationEventPublisher publisher = event -> { published.add(event); return Mono.empty(); }; + private final AtomicInteger completed = new AtomicInteger(); + private final OrchestrationEvents events = new OrchestrationEvents() { + @Override public void onCompleted(String name, String correlationId, ExecutionPattern pattern, + boolean success, long duration) { completed.incrementAndGet(); } + }; + + @ParameterizedTest + @CsvSource({"1,false,0,0,INITIAL,TRYING", "1,true,0,0,INITIAL,TRYING", + "2,false,1,0,CHECKPOINT,CONFIRMING", "2,true,1,0,CHECKPOINT,CONFIRMING", + "3,false,1,1,CHECKPOINT,CONFIRMED", "3,true,1,1,CHECKPOINT,CONFIRMED", + "4,false,1,1,FINAL,CONFIRMED", "4,true,1,1,FINAL,CONFIRMED"}) + void requiredFailureStopsAtTheFailedAcknowledgment(int failAt, boolean synchronous, + int tries, int confirms, TccPersistenceException.Stage stage, ExecutionStatus status) throws Exception { + var provider = new FailingProvider(failAt, synchronous); + var callbacks = new Callbacks(); + var counts = new Counts(); + var definition = definition(counts, callbacks, false, false); + var context = ExecutionContext.forTcc("required-correlation", definition.name); + context.putVariable("recoveryMarker", "retained"); + var execution = engine(provider, TccPersistenceMode.REQUIRED).execute(definition, TccInputs.empty(), context); + assertThat(provider.attempted).isEmpty(); + assertThat(counts.tries).hasValue(0); + assertThat(published).isEmpty(); + + StepVerifier.create(execution).expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(TccPersistenceException.class); + var failure = (TccPersistenceException) error; + assertThat(failure.getCause()).isSameAs(provider.cause); + assertThat(failure.getStage()).isEqualTo(stage); + assertThat(failure.getAttemptedStatus()).isEqualTo(status); + assertThat(failure.getCorrelationId()).isEqualTo(context.getCorrelationId()); + assertThat(failure.hasPossibleParticipantEffects()).isEqualTo(failAt != 1); + assertThat(failure.getExecutionContext()).isSameAs(context); + assertThat(failure.getExecutionContext().getVariable("recoveryMarker")).isEqualTo("retained"); + assertThat(failure.getMessage()).doesNotContain("private-provider-detail"); + assertThat(failure.getErrorCode()).isEqualTo("ORCHESTRATION_TCC_PERSISTENCE_ERROR"); + }).verify(Duration.ofSeconds(5)); + + assertThat(counts.tries).hasValue(tries); + assertThat(counts.confirms).hasValue(confirms); + assertThat(counts.cancels).hasValue(0); + assertThat(provider.attempted).hasSize(failAt); + assertThat(provider.accepted).hasSize(failAt - 1); + assertThat(provider.attempted).noneMatch(state -> state.status() == ExecutionStatus.FAILED); + assertThat(completed).hasValue(0); + assertThat(published).noneMatch(event -> event.eventType().equals("EXECUTION_COMPLETED")); + assertThat(callbacks.syncComplete).hasValue(0); + assertThat(callbacks.error).hasValue(0); + assertThat(callbacks.asyncComplete.await(100, TimeUnit.MILLISECONDS)).isFalse(); + if (tries > 0) assertThat(context.getTryResult("reservation")).isEqualTo("reserved"); + } + + @ParameterizedTest + @CsvSource({"false", "true"}) + void cancelCheckpointFailureDoesNotBeginCompensation(boolean synchronous) throws Exception { + var provider = new FailingProvider(2, synchronous); + var counts = new Counts(); + var callbacks = new Callbacks(); + var tcc = definition(counts, callbacks, true, false); + StepVerifier.create(engine(provider, TccPersistenceMode.REQUIRED).execute(tcc, TccInputs.empty())) + .expectErrorSatisfies(error -> { + var failure = (TccPersistenceException) error; + assertThat(failure.getAttemptedStatus()).isEqualTo(ExecutionStatus.CANCELING); + assertThat(failure.getStage()).isEqualTo(TccPersistenceException.Stage.CHECKPOINT); + assertThat(failure.hasPossibleParticipantEffects()).isTrue(); + assertThat(failure.getExecutionContext().getTryResult("reservation")).isEqualTo("reserved"); + }).verify(); + assertThat(counts.tries).hasValue(2); + assertThat(counts.confirms).hasValue(0); + assertThat(counts.cancels).hasValue(0); + assertThat(provider.attempted).extracting(ExecutionState::status) + .containsExactly(ExecutionStatus.TRYING, ExecutionStatus.CANCELING); + assertThat(callbacks.error).hasValue(0); + assertThat(completed).hasValue(0); + } + + @Test + void successObserversAndBothKindsOfCompleteCallbackWaitForFinalAcknowledgment() throws Exception { + var finalAck = Sinks.empty(); + var provider = new FailingProvider(0, false) { + @Override public Mono save(ExecutionState state) { + if (state.report().isPresent()) { + attempted.add(state); + return finalAck.asMono().then(Mono.defer(() -> super.accept(state))); + } + return super.save(state); + } + }; + var callbacks = new Callbacks(); + var counts = new Counts(); + var result = new AtomicReference(); + var failure = new AtomicReference(); + var subscription = engine(provider, TccPersistenceMode.REQUIRED) + .execute(definition(counts, callbacks, false, false), TccInputs.empty()) + .subscribe(result::set, failure::set); + try { + assertThat(counts.confirms).hasValue(1); + assertThat(provider.accepted).hasSize(3); + assertThat(result.get()).isNull(); + assertThat(completed).hasValue(0); + assertThat(published).noneMatch(event -> event.eventType().equals("EXECUTION_COMPLETED")); + assertThat(callbacks.syncComplete).hasValue(0); + assertThat(callbacks.asyncComplete.await(100, TimeUnit.MILLISECONDS)).isFalse(); + finalAck.tryEmitEmpty(); + assertThat(callbacks.asyncComplete.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(failure.get()).isNull(); + assertThat(result.get()).isNotNull().matches(TccResult::isConfirmed); + assertThat(provider.accepted).hasSize(4); + assertThat(completed).hasValue(1); + assertThat(callbacks.syncComplete).hasValue(1); + assertThat(published).filteredOn(event -> event.eventType().equals("EXECUTION_COMPLETED")) + .extracting(OrchestrationEvent::status).containsExactly(ExecutionStatus.CONFIRMED); + } finally { subscription.dispose(); } + } + + @Test + void genuineParticipantFailureStillCompensatesAfterAcknowledgedCancelCheckpoint() { + var provider = new FailingProvider(0, false); + var counts = new Counts(); + StepVerifier.create(engine(provider, TccPersistenceMode.REQUIRED) + .execute(definition(counts, new Callbacks(), true, false), TccInputs.empty())) + .assertNext(result -> assertThat(result.isCanceled()).isTrue()).verifyComplete(); + assertThat(counts.tries).hasValue(2); + assertThat(counts.confirms).hasValue(0); + assertThat(counts.cancels).hasValue(1); + assertThat(provider.accepted).extracting(ExecutionState::status) + .containsExactly(ExecutionStatus.TRYING, ExecutionStatus.CANCELING, ExecutionStatus.CANCELED); + } + + @Test + void genuineConfirmFailureStillCompensatesInRequiredMode() { + var provider = new FailingProvider(0, false); + var counts = new Counts(); + var tcc = TccBuilder.tccNoRetry("confirm-failure").participant("reservation") + .tryHandler((input, ctx) -> { counts.tries.incrementAndGet(); return Mono.just("reserved"); }) + .confirmHandler((input, ctx) -> { counts.confirms.incrementAndGet(); + return Mono.error(new IllegalStateException("participant confirm failure")); }) + .cancelHandler((input, ctx) -> { counts.cancels.incrementAndGet(); return Mono.just("canceled"); }) + .add().build(); + StepVerifier.create(engine(provider, TccPersistenceMode.REQUIRED).execute(tcc, TccInputs.empty())) + .assertNext(result -> assertThat(result.isCanceled()).isTrue()).verifyComplete(); + assertThat(counts.tries).hasValue(1); + assertThat(counts.confirms).hasValue(1); + assertThat(counts.cancels).hasValue(1); + assertThat(provider.accepted).extracting(ExecutionState::status).containsExactly( + ExecutionStatus.TRYING, ExecutionStatus.CONFIRMING, ExecutionStatus.CANCELING, ExecutionStatus.CANCELED); + } + + @Test + void providerThatWritesBeforeFailingAcknowledgmentStillStopsWithoutCompensation() { + var provider = new FailingProvider(0, false) { + @Override public Mono save(ExecutionState state) { + var saved = super.save(state); + return attempted.size() == 3 ? saved.then(Mono.error(cause)) : saved; + } + }; + var counts = new Counts(); + StepVerifier.create(engine(provider, TccPersistenceMode.REQUIRED) + .execute(definition(counts, new Callbacks(), false, false), TccInputs.empty())) + .expectErrorSatisfies(error -> { + var failure = (TccPersistenceException) error; + assertThat(failure.hasPossibleParticipantEffects()).isTrue(); + assertThat(failure.getCause()).isSameAs(provider.cause); + assertThat(provider.findById(failure.getCorrelationId()).block().orElseThrow().status()) + .isEqualTo(ExecutionStatus.CONFIRMED); + }).verify(); + assertThat(counts.tries).hasValue(1); + assertThat(counts.confirms).hasValue(1); + assertThat(counts.cancels).hasValue(0); + assertThat(provider.attempted).hasSize(3); + assertThat(provider.accepted).hasSize(3); + assertThat(completed).hasValue(0); + } + + @Test + void finalFailedSaveDoesNotBecomeSuppressedSuccessOrRunCompleteCallbacks() throws Exception { + var provider = new FailingProvider(3, false); + var counts = new Counts(); + var callbacks = new Callbacks(); + StepVerifier.create(engine(provider, TccPersistenceMode.REQUIRED) + .execute(definition(counts, callbacks, true, true), TccInputs.empty())) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(TccPersistenceException.class); + assertThat(((TccPersistenceException) error).getStage()).isEqualTo(TccPersistenceException.Stage.FINAL); + assertThat(error.getCause()).isSameAs(provider.cause); + }).verify(); + assertThat(counts.cancels).hasValue(1); + // This callback handles the genuine cancel failure, not the later persistence failure. + assertThat(callbacks.error).hasValue(1); + assertThat(callbacks.syncComplete).hasValue(0); + assertThat(callbacks.asyncComplete.await(100, TimeUnit.MILLISECONDS)).isFalse(); + assertThat(provider.attempted).hasSize(3); + assertThat(completed).hasValue(0); + } + + @ParameterizedTest + @CsvSource({"false,CANCELED", "true,FAILED"}) + void failedFinalAcknowledgmentAlsoPropagatesForCanceledAndUnsuppressedFailedResults( + boolean failCancel, ExecutionStatus attemptedStatus) { + var provider = new FailingProvider(3, false); + var counts = new Counts(); + var tcc = definition(counts, new Callbacks(), true, failCancel); + tcc.onTccErrorMethods = List.of(); + StepVerifier.create(engine(provider, TccPersistenceMode.REQUIRED).execute(tcc, TccInputs.empty())) + .expectErrorSatisfies(error -> { + var failure = (TccPersistenceException) error; + assertThat(failure.getStage()).isEqualTo(TccPersistenceException.Stage.FINAL); + assertThat(failure.getAttemptedStatus()).isEqualTo(attemptedStatus); + assertThat(failure.getCause()).isSameAs(provider.cause); + }).verify(); + assertThat(counts.cancels).hasValue(1); + assertThat(provider.attempted).hasSize(3); + assertThat(provider.accepted).hasSize(2); + assertThat(completed).hasValue(0); + assertThat(published).noneMatch(event -> event.eventType().equals("EXECUTION_COMPLETED")); + } + + @Test + void requiredNeedsProviderAndMatchingManualEngineOrchestratorPolicies() { + assertThatIllegalArgumentException().isThrownBy(() -> new TccExecutionOrchestrator(invoker, + events, publisher, null, TccPersistenceMode.REQUIRED)); + var provider = new FailingProvider(0, false); + var bestEffort = new TccExecutionOrchestrator(invoker, events, publisher, provider); + assertThatIllegalArgumentException().isThrownBy(() -> new TccEngine(null, events, bestEffort, + provider, null, publisher, null, TccPersistenceMode.REQUIRED)); + var required = new TccExecutionOrchestrator(invoker, events, publisher, provider, TccPersistenceMode.REQUIRED); + assertThatIllegalArgumentException().isThrownBy(() -> new TccEngine(null, events, required, + provider, null, publisher)); + assertThatIllegalArgumentException().isThrownBy(() -> new TccEngine(null, events, required, + null, null, publisher, null, TccPersistenceMode.REQUIRED)); + assertThatIllegalArgumentException().isThrownBy(() -> new TccEngine(null, events, required, + new InMemoryPersistenceProvider(), null, publisher, null, TccPersistenceMode.REQUIRED)); + } + + @Test + void existingConstructorsRemainBestEffortAndPermitAbsentCheckpointProvider() { + var counts = new Counts(); + var provider = new FailingProvider(1, false); + var oldOrchestrator = new TccExecutionOrchestrator(invoker, events, new NoOpEventPublisher()); + var oldEngine = new TccEngine(null, events, oldOrchestrator, provider, null, publisher); + StepVerifier.create(oldEngine.execute(definition(counts, new Callbacks(), false, false), TccInputs.empty())) + .assertNext(result -> assertThat(result.isConfirmed()).isTrue()).verifyComplete(); + assertThat(counts.tries).hasValue(1); + assertThat(counts.confirms).hasValue(1); + assertThat(counts.cancels).hasValue(0); + } + + private TccEngine engine(FailingProvider provider, TccPersistenceMode mode) { + return new TccEngine(null, events, + new TccExecutionOrchestrator(invoker, events, publisher, provider, mode), + provider, null, publisher, null, mode); + } + + private TccDefinition definition(Counts counts, Callbacks callbacks, boolean failTry, boolean failCancel) + throws RuntimeException { + var builder = TccBuilder.tccNoRetry("required-persistence").participant("reservation") + .tryHandler((input, ctx) -> { counts.tries.incrementAndGet(); return Mono.just("reserved"); }) + .confirmHandler((input, ctx) -> { counts.confirms.incrementAndGet(); return Mono.just("confirmed"); }) + .cancelHandler((input, ctx) -> { counts.cancels.incrementAndGet(); + return failCancel ? Mono.error(new IllegalStateException("participant cancel failure")) : Mono.just("canceled"); }) + .add(); + if (failTry) builder.participant("rejected").tryHandler((input, ctx) -> { + counts.tries.incrementAndGet(); return Mono.error(new IllegalStateException("participant try failure")); }) + .confirmHandler((input, ctx) -> Mono.just("unexpected")) + .cancelHandler((input, ctx) -> Mono.just("unexpected")).add(); + var base = builder.build(); + var tcc = new TccDefinition(base.name, callbacks, callbacks, base.timeoutMs, false, 0, 0); + tcc.participants.putAll(base.participants); + try { + tcc.onTccCompleteMethods = List.of(Callbacks.class.getDeclaredMethod("complete"), + Callbacks.class.getDeclaredMethod("asyncComplete")); + tcc.onTccErrorMethods = List.of(Callbacks.class.getDeclaredMethod("error")); + } catch (NoSuchMethodException e) { throw new AssertionError(e); } + return tcc; + } + + static class Counts { + final AtomicInteger tries = new AtomicInteger(); + final AtomicInteger confirms = new AtomicInteger(); + final AtomicInteger cancels = new AtomicInteger(); + } + + static class Callbacks { + final AtomicInteger syncComplete = new AtomicInteger(); + final CountDownLatch asyncComplete = new CountDownLatch(1); + final AtomicInteger error = new AtomicInteger(); + @OnTccComplete public void complete() { syncComplete.incrementAndGet(); } + @OnTccComplete(async = true) public void asyncComplete() { asyncComplete.countDown(); } + @OnTccError(suppressError = true) public void error() { error.incrementAndGet(); } + } + + static class FailingProvider extends InMemoryPersistenceProvider { + final List attempted = new ArrayList<>(); + final List accepted = new ArrayList<>(); + final IllegalStateException cause = new IllegalStateException("private-provider-detail"); + private final int failAt; + private final boolean synchronous; + FailingProvider(int failAt, boolean synchronous) { this.failAt = failAt; this.synchronous = synchronous; } + @Override public Mono save(ExecutionState state) { + attempted.add(state); + if (attempted.size() == failAt) { + if (synchronous) throw cause; + return Mono.error(cause); + } + return accept(state); + } + Mono accept(ExecutionState state) { + return super.save(state).doOnSuccess(ignored -> accepted.add(state)); + } + } +} diff --git a/modules/plugins/README.md b/modules/plugins/README.md index cff50bac2..8eb6d9dec 100644 --- a/modules/plugins/README.md +++ b/modules/plugins/README.md @@ -29,7 +29,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework plugin-api - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/plugins/plugin-api/README.md b/modules/plugins/plugin-api/README.md index 16ba3069c..e0cf40ae1 100644 --- a/modules/plugins/plugin-api/README.md +++ b/modules/plugins/plugin-api/README.md @@ -20,7 +20,7 @@ Define a host extension contract for an onboarding check and implement it in an org.fireflyframework plugin-api - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/plugins/plugin-api/pom.xml b/modules/plugins/plugin-api/pom.xml index edc74e3a4..2437d9830 100644 --- a/modules/plugins/plugin-api/pom.xml +++ b/modules/plugins/plugin-api/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-plugins - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/plugins/plugin-core/README.md b/modules/plugins/plugin-core/README.md index 49685fcef..8be9ee6fb 100644 --- a/modules/plugins/plugin-core/README.md +++ b/modules/plugins/plugin-core/README.md @@ -20,7 +20,7 @@ The onboarding host can load an application plugin class and start its contribut org.fireflyframework plugin-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/plugins/plugin-core/pom.xml b/modules/plugins/plugin-core/pom.xml index 7d24ea00a..2c30b26ec 100644 --- a/modules/plugins/plugin-core/pom.xml +++ b/modules/plugins/plugin-core/pom.xml @@ -7,7 +7,7 @@ org.fireflyframework fireflyframework-plugins - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/plugins/pom.xml b/modules/plugins/pom.xml index 2393e21da..150698a8b 100644 --- a/modules/plugins/pom.xml +++ b/modules/plugins/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-plugins - 26.10.21-SNAPSHOT + 26.10.22 pom Firefly Framework - Common Plugins diff --git a/modules/r2dbc/README.md b/modules/r2dbc/README.md index 71d1b0260..c3c8686ed 100644 --- a/modules/r2dbc/README.md +++ b/modules/r2dbc/README.md @@ -20,7 +20,7 @@ The tutorial saves applications in a process-local map. An application-owned R2D org.fireflyframework fireflyframework-r2dbc - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/r2dbc/pom.xml b/modules/r2dbc/pom.xml index 43c5a9d3b..56a902ec5 100644 --- a/modules/r2dbc/pom.xml +++ b/modules/r2dbc/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-r2dbc - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - R2DBC diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/config/R2dbcAutoConfiguration.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/config/R2dbcAutoConfiguration.java index a32bed22a..b283d28ef 100644 --- a/modules/r2dbc/src/main/java/org/fireflyframework/core/config/R2dbcAutoConfiguration.java +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/config/R2dbcAutoConfiguration.java @@ -3,9 +3,13 @@ import org.fireflyframework.core.filters.FilterUtils; import jakarta.annotation.PostConstruct; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; -@AutoConfiguration +@AutoConfiguration(afterName = "org.springframework.boot.data.r2dbc.autoconfigure.DataR2dbcAutoConfiguration") +@ConditionalOnClass(R2dbcEntityTemplate.class) +@ConditionalOnBean(R2dbcEntityTemplate.class) public class R2dbcAutoConfiguration { private final R2dbcEntityTemplate template; @@ -17,4 +21,4 @@ public R2dbcAutoConfiguration(R2dbcEntityTemplate template) { public void initialize() { FilterUtils.initializeTemplate(template); } -} \ No newline at end of file +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/config/R2dbcTransactionAutoConfiguration.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/config/R2dbcTransactionAutoConfiguration.java index b23981d78..0812d3d10 100644 --- a/modules/r2dbc/src/main/java/org/fireflyframework/core/config/R2dbcTransactionAutoConfiguration.java +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/config/R2dbcTransactionAutoConfiguration.java @@ -7,6 +7,8 @@ import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; /** * Configuration class for managing R2DBC transactions in a reactive application. @@ -25,11 +27,16 @@ * Bean Definitions: * - {@link ReactiveTransactionManager}: Configured to manage transactions for R2DBC-based reactive data sources. */ -@AutoConfiguration +@AutoConfiguration(afterName = { + "org.springframework.boot.r2dbc.autoconfigure.R2dbcAutoConfiguration", + "org.springframework.boot.r2dbc.autoconfigure.R2dbcTransactionManagerAutoConfiguration" +}) +@ConditionalOnClass({ConnectionFactory.class, R2dbcTransactionManager.class, ReactiveTransactionManager.class}) +@ConditionalOnSingleCandidate(ConnectionFactory.class) @EnableTransactionManagement public class R2dbcTransactionAutoConfiguration { - @ConditionalOnMissingBean + @ConditionalOnMissingBean(ReactiveTransactionManager.class) @Bean public ReactiveTransactionManager transactionManager(ConnectionFactory connectionFactory) { return new R2dbcTransactionManager(connectionFactory); diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterRequest.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterRequest.java index 770a71669..cfe5eafe7 100644 --- a/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterRequest.java +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterRequest.java @@ -9,7 +9,11 @@ import lombok.experimental.SuperBuilder; import java.util.HashMap; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.util.Collections; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @Data @@ -32,9 +36,46 @@ public class FilterRequest { public static final Object NOT_NULL_VALUE = new Object(); /** - * Map to store special filter values (NULL, NOT_NULL) for fields + * Markers belong to the exact live filter instance, independent of mutable DTO equality. */ - private static final Map> SPECIAL_FILTERS = new ConcurrentHashMap<>(); + @Schema(hidden = true) + private static final Map> SPECIAL_FILTERS = new ConcurrentHashMap<>(); + @Schema(hidden = true) + private static final ReferenceQueue CLEARED_FILTERS = new ReferenceQueue<>(); + + private static final class IdentityKey extends WeakReference { + private final int identityHash; + + private IdentityKey(Object filter, ReferenceQueue queue) { + super(Objects.requireNonNull(filter), queue); + identityHash = System.identityHashCode(filter); + } + + @Override + public int hashCode() { + return identityHash; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + Object filter = get(); + return filter != null && other instanceof IdentityKey key && filter == key.get(); + } + } + + private static void discardClearedFilters() { + IdentityKey cleared; + while ((cleared = (IdentityKey) CLEARED_FILTERS.poll()) != null) { + SPECIAL_FILTERS.remove(cleared); + } + } + + private static void setSpecialFilter(Object filter, String fieldName, Object marker) { + discardClearedFilters(); + SPECIAL_FILTERS.computeIfAbsent(new IdentityKey(filter, CLEARED_FILTERS), + key -> Collections.synchronizedMap(new HashMap<>())).put(fieldName, marker); + } /** * Sets a field to be filtered for NULL values @@ -42,8 +83,7 @@ public class FilterRequest { * @param fieldName The name of the field to filter for NULL */ public static void setNullFilter(Object filter, String fieldName) { - SPECIAL_FILTERS.computeIfAbsent(filter, k -> new HashMap<>()) - .put(fieldName, NULL_VALUE); + setSpecialFilter(filter, fieldName, NULL_VALUE); } /** @@ -52,8 +92,7 @@ public static void setNullFilter(Object filter, String fieldName) { * @param fieldName The name of the field to filter for NOT NULL */ public static void setNotNullFilter(Object filter, String fieldName) { - SPECIAL_FILTERS.computeIfAbsent(filter, k -> new HashMap<>()) - .put(fieldName, NOT_NULL_VALUE); + setSpecialFilter(filter, fieldName, NOT_NULL_VALUE); } /** @@ -63,7 +102,8 @@ public static void setNotNullFilter(Object filter, String fieldName) { * @return The special filter value or null if none exists */ public static Object getSpecialFilter(Object filter, String fieldName) { - Map specialFilters = SPECIAL_FILTERS.get(filter); + discardClearedFilters(); + Map specialFilters = SPECIAL_FILTERS.get(new IdentityKey(filter, null)); return specialFilters != null ? specialFilters.get(fieldName) : null; } diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/config/R2dbcInfrastructureAutoConfigurationTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/config/R2dbcInfrastructureAutoConfigurationTest.java new file mode 100644 index 000000000..d89fd1129 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/config/R2dbcInfrastructureAutoConfigurationTest.java @@ -0,0 +1,110 @@ +package org.fireflyframework.core.config; + +import io.r2dbc.spi.ConnectionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.data.r2dbc.autoconfigure.DataR2dbcAutoConfiguration; +import org.springframework.boot.r2dbc.autoconfigure.R2dbcTransactionManagerAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.transaction.ReactiveTransactionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class R2dbcInfrastructureAutoConfigurationTest { + private final ApplicationContextRunner context = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(R2dbcAutoConfiguration.class, + R2dbcTransactionAutoConfiguration.class)); + + @Test + void domainWithoutDatabaseInfrastructureStartsWithoutDatabaseBeans() { + context.run(application -> { + assertThat(application).hasNotFailed(); + assertThat(application).doesNotHaveBean(R2dbcEntityTemplate.class); + assertThat(application).doesNotHaveBean(ReactiveTransactionManager.class); + }); + } + + @Test + void excludedTransactionDependencyDoesNotBreakEmbeddedRuleDomain() { + context.withClassLoader(new FilteredClassLoader("org.springframework.transaction")) + .run(application -> { + assertThat(application).hasNotFailed(); + assertThat(application).doesNotHaveBean("transactionManager"); + }); + } + + @Test + void absentR2dbcApiDoesNotLoadDatabaseConfiguration() { + context.withClassLoader(new FilteredClassLoader("io.r2dbc.spi", "org.springframework.data.r2dbc")) + .run(application -> { + assertThat(application).hasNotFailed(); + assertThat(application).doesNotHaveBean("transactionManager"); + }); + } + + @Test + void suppliedTemplateCanInitializeWithoutCreatingTransactionManager() { + context.withBean(R2dbcEntityTemplate.class, () -> mock(R2dbcEntityTemplate.class)) + .run(application -> { + assertThat(application).hasNotFailed(); + assertThat(application).hasSingleBean(R2dbcAutoConfiguration.class); + assertThat(application).doesNotHaveBean(ReactiveTransactionManager.class); + }); + } + + @Test + void suppliedConnectionFactoryGetsReactiveTransactionManager() { + ConnectionFactory factory = mock(ConnectionFactory.class); + context.withBean(ConnectionFactory.class, () -> factory).run(application -> { + assertThat(application).hasNotFailed(); + assertThat(application).hasSingleBean(ReactiveTransactionManager.class); + assertThat(application.getBean(ReactiveTransactionManager.class)) + .isInstanceOfSatisfying(R2dbcTransactionManager.class, + manager -> assertThat(manager.getConnectionFactory()).isSameAs(factory)); + }); + } + + @Test + void userTransactionManagerIsPreserved() { + ReactiveTransactionManager custom = mock(ReactiveTransactionManager.class); + context.withBean(ConnectionFactory.class, () -> mock(ConnectionFactory.class)) + .withBean("customReactiveTransactions", ReactiveTransactionManager.class, () -> custom) + .run(application -> { + assertThat(application).hasNotFailed(); + assertThat(application).hasSingleBean(ReactiveTransactionManager.class); + assertThat(application.getBean(ReactiveTransactionManager.class)).isSameAs(custom); + }); + } + + @Test + void ambiguousConnectionFactoriesDoNotChooseAnArbitraryDatabase() { + context.withBean("firstDatabase", ConnectionFactory.class, () -> mock(ConnectionFactory.class)) + .withBean("secondDatabase", ConnectionFactory.class, () -> mock(ConnectionFactory.class)) + .run(application -> { + assertThat(application).hasNotFailed(); + assertThat(application).doesNotHaveBean(ReactiveTransactionManager.class); + }); + } + + @Test + void realBootDataConfigurationRunsBeforeFrameworkTemplateInitialization() { + ConnectionFactory factory = mock(ConnectionFactory.class); + when(factory.getMetadata()).thenReturn(() -> "PostgreSQL"); + context.withConfiguration(AutoConfigurations.of( + org.springframework.boot.r2dbc.autoconfigure.R2dbcAutoConfiguration.class, + DataR2dbcAutoConfiguration.class, + R2dbcTransactionManagerAutoConfiguration.class)) + .withBean(ConnectionFactory.class, () -> factory) + .run(application -> { + assertThat(application).hasNotFailed(); + assertThat(application).hasSingleBean(R2dbcEntityTemplate.class); + assertThat(application).hasSingleBean(R2dbcAutoConfiguration.class); + assertThat(application).hasSingleBean(ReactiveTransactionManager.class); + }); + } +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterRequestMarkerIdentityTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterRequestMarkerIdentityTest.java new file mode 100644 index 000000000..12ed83409 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterRequestMarkerIdentityTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.filters; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; + +class FilterRequestMarkerIdentityTest { + static final class MutableFilter { + String value; + + @Override + public boolean equals(Object other) { + return other instanceof MutableFilter filter && Objects.equals(value, filter.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + } + + @Test + void equalInstancesDoNotShareNullMarkers() { + var left = new MutableFilter(); + var right = new MutableFilter(); + assertThat(left).isEqualTo(right); + FilterRequest.setNullFilter(left, "value"); + assertThat(FilterRequest.getSpecialFilter(left, "value")).isSameAs(FilterRequest.NULL_VALUE); + assertThat(FilterRequest.getSpecialFilter(right, "value")).isNull(); + } + + @Test + void equalInstancesRetainDifferentMarkersForTheSameField() { + var left = new MutableFilter(); + var right = new MutableFilter(); + FilterRequest.setNullFilter(left, "value"); + FilterRequest.setNotNullFilter(right, "value"); + assertThat(FilterRequest.getSpecialFilter(left, "value")).isSameAs(FilterRequest.NULL_VALUE); + assertThat(FilterRequest.getSpecialFilter(right, "value")).isSameAs(FilterRequest.NOT_NULL_VALUE); + } + + @Test + void markerSurvivesMutationOfItsOwnDtoHash() { + var filter = new MutableFilter(); + FilterRequest.setNullFilter(filter, "value"); + filter.value = "changed"; + assertThat(FilterRequest.getSpecialFilter(filter, "value")).isSameAs(FilterRequest.NULL_VALUE); + } + + @Test + void registryDoesNotInvokeUserEqualityOrHashCode() { + Object filter = new Object() { + @Override + public int hashCode() { throw new AssertionError("User hashCode must not run"); } + @Override + public boolean equals(Object other) { throw new AssertionError("User equals must not run"); } + }; + FilterRequest.setNotNullFilter(filter, "field"); + assertThat(FilterRequest.getSpecialFilter(filter, "field")).isSameAs(FilterRequest.NOT_NULL_VALUE); + } + + @Test + void concurrentUpdatesKeepEveryFieldMarker() throws Exception { + var filter = new MutableFilter(); + try (var executor = Executors.newFixedThreadPool(8)) { + var work = new java.util.ArrayList>(); + for (int i = 0; i < 128; i++) { + int index = i; + work.add(() -> { + if (index % 2 == 0) FilterRequest.setNullFilter(filter, "field" + index); + else FilterRequest.setNotNullFilter(filter, "field" + index); + return null; + }); + } + for (var future : executor.invokeAll(work)) future.get(); + } + for (int i = 0; i < 128; i++) { + assertThat(FilterRequest.getSpecialFilter(filter, "field" + i)) + .isSameAs(i % 2 == 0 ? FilterRequest.NULL_VALUE : FilterRequest.NOT_NULL_VALUE); + } + } + + @Test + void replacingAMarkerOnlyAffectsItsExactObjectAndField() { + var filter = new MutableFilter(); + FilterRequest.setNullFilter(filter, "first"); + FilterRequest.setNotNullFilter(filter, "second"); + FilterRequest.setNotNullFilter(filter, "first"); + assertThat(FilterRequest.getSpecialFilter(filter, "first")).isSameAs(FilterRequest.NOT_NULL_VALUE); + assertThat(FilterRequest.getSpecialFilter(filter, "second")).isSameAs(FilterRequest.NOT_NULL_VALUE); + assertThat(FilterRequest.getSpecialFilter(filter, "third")).isNull(); + } +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterRequestOpenApiTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterRequestOpenApiTest.java new file mode 100644 index 000000000..4359fa433 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterRequestOpenApiTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.filters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.springdoc.core.extractor.MethodParameterPojoExtractor; +import org.springdoc.core.utils.SchemaUtils; +import org.springframework.core.MethodParameter; +import org.springframework.test.util.ReflectionTestUtils; + +class FilterRequestOpenApiTest { + public static final class Criteria { + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + + public static final class TypedRequest extends FilterRequest {} + + private static List parameters(Class type) { + var extractor = new MethodParameterPojoExtractor(mock(SchemaUtils.class)); + Stream parameters = ReflectionTestUtils.invokeMethod(extractor, "extractFrom", type); + try (parameters) { + return parameters.map(MethodParameter::getParameterName).toList(); + } + } + + @Test + void typedParameterObjectKeepsPublicCriteriaAndPaginationWithoutInternalRegistryTraversal() { + var names = assertDoesNotThrow(() -> parameters(TypedRequest.class)); + assertThat(names).contains("filters.name", "pagination.pageNumber", "pagination.pageSize"); + assertThat(names).noneMatch(name -> name.contains("SPECIAL_FILTERS") || name.contains("CLEARED_FILTERS")); + } + + @Test + void rawParameterObjectStillExposesPaginationWithoutInternalRegistryTraversal() { + var names = assertDoesNotThrow(() -> parameters(FilterRequest.class)); + assertThat(names).contains("pagination.pageNumber", "pagination.pageSize", "pagination.sortBy"); + assertThat(names).noneMatch(name -> name.contains("SPECIAL_FILTERS") || name.contains("CLEARED_FILTERS")); + } +} diff --git a/modules/rule-engine/README.md b/modules/rule-engine/README.md index 2ed84581a..7e9b250f7 100644 --- a/modules/rule-engine/README.md +++ b/modules/rule-engine/README.md @@ -32,7 +32,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-rule-engine-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/rule-engine/fireflyframework-rule-engine-core/README.md b/modules/rule-engine/fireflyframework-rule-engine-core/README.md index 02d1eb03f..a8efefef6 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-core/README.md +++ b/modules/rule-engine/fireflyframework-rule-engine-core/README.md @@ -20,7 +20,7 @@ Evaluate a validated onboarding rule against application input through the parse org.fireflyframework fireflyframework-rule-engine-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/rule-engine/fireflyframework-rule-engine-core/pom.xml b/modules/rule-engine/fireflyframework-rule-engine-core/pom.xml index 97d09af28..61de14cd5 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-core/pom.xml +++ b/modules/rule-engine/fireflyframework-rule-engine-core/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-rule-engine - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/rule-engine/fireflyframework-rule-engine-interfaces/README.md b/modules/rule-engine/fireflyframework-rule-engine-interfaces/README.md index ee7feec2a..9e6b1bff8 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-interfaces/README.md +++ b/modules/rule-engine/fireflyframework-rule-engine-interfaces/README.md @@ -20,7 +20,7 @@ Choose the request DTO for plain YAML, encoded YAML, or a stored onboarding rule org.fireflyframework fireflyframework-rule-engine-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/rule-engine/fireflyframework-rule-engine-interfaces/pom.xml b/modules/rule-engine/fireflyframework-rule-engine-interfaces/pom.xml index 371242bed..587a305d6 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-interfaces/pom.xml +++ b/modules/rule-engine/fireflyframework-rule-engine-interfaces/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-rule-engine - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/rule-engine/fireflyframework-rule-engine-models/README.md b/modules/rule-engine/fireflyframework-rule-engine-models/README.md index f6b6ecf9a..81b3ae3a6 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-models/README.md +++ b/modules/rule-engine/fireflyframework-rule-engine-models/README.md @@ -20,7 +20,7 @@ Use these repositories when onboarding rules and constants need a stored, versio org.fireflyframework fireflyframework-rule-engine-models - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/rule-engine/fireflyframework-rule-engine-models/pom.xml b/modules/rule-engine/fireflyframework-rule-engine-models/pom.xml index 1e7e770c1..3ab975793 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-models/pom.xml +++ b/modules/rule-engine/fireflyframework-rule-engine-models/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-rule-engine - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/rule-engine/fireflyframework-rule-engine-sdk/README.md b/modules/rule-engine/fireflyframework-rule-engine-sdk/README.md index 0e43216e0..e9a81f31d 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-sdk/README.md +++ b/modules/rule-engine/fireflyframework-rule-engine-sdk/README.md @@ -20,7 +20,7 @@ A separate onboarding service can call the rule service through generated WebCli org.fireflyframework fireflyframework-rule-engine-sdk - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/rule-engine/fireflyframework-rule-engine-sdk/pom.xml b/modules/rule-engine/fireflyframework-rule-engine-sdk/pom.xml index 9ea0bf943..6da911025 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-sdk/pom.xml +++ b/modules/rule-engine/fireflyframework-rule-engine-sdk/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-rule-engine - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/rule-engine/fireflyframework-rule-engine-web/README.md b/modules/rule-engine/fireflyframework-rule-engine-web/README.md index ce8eff039..eac3a4a7e 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-web/README.md +++ b/modules/rule-engine/fireflyframework-rule-engine-web/README.md @@ -20,7 +20,7 @@ Run the rule service when onboarding applications evaluate rules over HTTP or ma org.fireflyframework fireflyframework-rule-engine-web - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/rule-engine/fireflyframework-rule-engine-web/pom.xml b/modules/rule-engine/fireflyframework-rule-engine-web/pom.xml index 23f53a569..69663b211 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-web/pom.xml +++ b/modules/rule-engine/fireflyframework-rule-engine-web/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-rule-engine - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/rule-engine/pom.xml b/modules/rule-engine/pom.xml index aab148d7a..f51292c06 100644 --- a/modules/rule-engine/pom.xml +++ b/modules/rule-engine/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-rule-engine - 26.10.21-SNAPSHOT + 26.10.22 pom Firefly Framework - Rule Engine Library diff --git a/modules/security-adapter-cerbos/README.md b/modules/security-adapter-cerbos/README.md index 70bdb23f3..a56b7899d 100644 --- a/modules/security-adapter-cerbos/README.md +++ b/modules/security-adapter-cerbos/README.md @@ -20,7 +20,7 @@ A reviewer action can be checked against Cerbos when this adapter is the configu org.fireflyframework fireflyframework-security-adapter-cerbos - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-adapter-cerbos/pom.xml b/modules/security-adapter-cerbos/pom.xml index 9b8170014..71618bc60 100644 --- a/modules/security-adapter-cerbos/pom.xml +++ b/modules/security-adapter-cerbos/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-adapter-cerbos - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security Cerbos Adapter diff --git a/modules/security-adapter-opa/README.md b/modules/security-adapter-opa/README.md index f8ff753db..39b1c7ce4 100644 --- a/modules/security-adapter-opa/README.md +++ b/modules/security-adapter-opa/README.md @@ -20,7 +20,7 @@ Delegate an onboarding authorization decision to a configured OPA policy through org.fireflyframework fireflyframework-security-adapter-opa - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-adapter-opa/pom.xml b/modules/security-adapter-opa/pom.xml index bb55b0961..b24416017 100644 --- a/modules/security-adapter-opa/pom.xml +++ b/modules/security-adapter-opa/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-adapter-opa - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security OPA Adapter diff --git a/modules/security-adapter-openfga/README.md b/modules/security-adapter-openfga/README.md index 7e9e1e371..72efe977e 100644 --- a/modules/security-adapter-openfga/README.md +++ b/modules/security-adapter-openfga/README.md @@ -20,7 +20,7 @@ Use RelationshipPort to check whether a reviewer is related to a particular appl org.fireflyframework fireflyframework-security-adapter-openfga - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-adapter-openfga/pom.xml b/modules/security-adapter-openfga/pom.xml index 060f70a7f..c42f42556 100644 --- a/modules/security-adapter-openfga/pom.xml +++ b/modules/security-adapter-openfga/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-adapter-openfga - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security OpenFGA Adapter diff --git a/modules/security-adapter-r2dbc/README.md b/modules/security-adapter-r2dbc/README.md index 88b31b113..dfb7ad9b5 100644 --- a/modules/security-adapter-r2dbc/README.md +++ b/modules/security-adapter-r2dbc/README.md @@ -20,7 +20,7 @@ Store revoked token IDs when the application uses PostgreSQL for that security s org.fireflyframework fireflyframework-security-adapter-r2dbc - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-adapter-r2dbc/pom.xml b/modules/security-adapter-r2dbc/pom.xml index 40c0e3276..815184604 100644 --- a/modules/security-adapter-r2dbc/pom.xml +++ b/modules/security-adapter-r2dbc/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-adapter-r2dbc - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security R2DBC Adapter diff --git a/modules/security-adapter-vault/README.md b/modules/security-adapter-vault/README.md index f922c4015..a6b62982b 100644 --- a/modules/security-adapter-vault/README.md +++ b/modules/security-adapter-vault/README.md @@ -20,7 +20,7 @@ Resolve a verification partner's credential through SecretsPort at runtime. Prov org.fireflyframework fireflyframework-security-adapter-vault - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-adapter-vault/pom.xml b/modules/security-adapter-vault/pom.xml index 661ee8f91..2f7f63ca6 100644 --- a/modules/security-adapter-vault/pom.xml +++ b/modules/security-adapter-vault/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-adapter-vault - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security Vault Adapter diff --git a/modules/security-api/README.md b/modules/security-api/README.md index 63c8d0631..17bc28afd 100644 --- a/modules/security-api/README.md +++ b/modules/security-api/README.md @@ -20,7 +20,7 @@ Represent an authenticated applicant or reviewer with SecurityPrincipal and eval org.fireflyframework fireflyframework-security-api - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-api/pom.xml b/modules/security-api/pom.xml index 883f9b155..d378ce1b6 100644 --- a/modules/security-api/pom.xml +++ b/modules/security-api/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-api - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security API diff --git a/modules/security-authorization-server/README.md b/modules/security-authorization-server/README.md index 4c6215cc6..6ae2221bb 100644 --- a/modules/security-authorization-server/README.md +++ b/modules/security-authorization-server/README.md @@ -20,7 +20,7 @@ Use this separate servlet service when the deployment needs to issue its own OAu org.fireflyframework fireflyframework-security-authorization-server - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-authorization-server/pom.xml b/modules/security-authorization-server/pom.xml index 7057d91d7..57d1167ed 100644 --- a/modules/security-authorization-server/pom.xml +++ b/modules/security-authorization-server/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-authorization-server - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security Authorization Server diff --git a/modules/security-bff/README.md b/modules/security-bff/README.md index c52d7c9d9..d5ff08038 100644 --- a/modules/security-bff/README.md +++ b/modules/security-bff/README.md @@ -20,7 +20,7 @@ A browser applicant portal can keep tokens in a server-side session and present org.fireflyframework fireflyframework-security-bff - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-bff/pom.xml b/modules/security-bff/pom.xml index 361183045..91e68ac87 100644 --- a/modules/security-bff/pom.xml +++ b/modules/security-bff/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-bff - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security BFF diff --git a/modules/security-core/README.md b/modules/security-core/README.md index fafb86446..b71e9a3f7 100644 --- a/modules/security-core/README.md +++ b/modules/security-core/README.md @@ -20,7 +20,7 @@ Map the authenticated applicant/reviewer claims and evaluate the configured auth org.fireflyframework fireflyframework-security-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-core/pom.xml b/modules/security-core/pom.xml index 4e226b8e5..d5cf948d1 100644 --- a/modules/security-core/pom.xml +++ b/modules/security-core/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-core - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security Core diff --git a/modules/security-core/src/main/java/org/fireflyframework/security/core/policy/EmbeddedPolicyDecisionAdapter.java b/modules/security-core/src/main/java/org/fireflyframework/security/core/policy/EmbeddedPolicyDecisionAdapter.java index eb29a3d13..4ce1d0505 100644 --- a/modules/security-core/src/main/java/org/fireflyframework/security/core/policy/EmbeddedPolicyDecisionAdapter.java +++ b/modules/security-core/src/main/java/org/fireflyframework/security/core/policy/EmbeddedPolicyDecisionAdapter.java @@ -29,7 +29,7 @@ /** * Zero-dependency, in-process default {@link PolicyDecisionPort}. Combines registered - * {@link PolicyRule}s with deny-overrides semantics and is strictly fail-closed: + * {@link PolicyRule}s with deny-overrides semantics. The legacy constructor uses these rules: * *
      *
    • No rules registered → PERMIT (ABAC not in use; defer to RBAC at the PEP).
    • @@ -38,25 +38,40 @@ *
    • Rules registered but none permit (all abstain) → DENY.
    • *
    • A rule errors → treated as INDETERMINATE (never PERMIT).
    • *
    + * + *

    The strict constructor requires an explicit permit, denies failed or empty rule publishers, + * and retains permit obligations for the enforcing caller. The original constructor preserves + * the legacy no-rules deferral to a separate RBAC gate. */ public class EmbeddedPolicyDecisionAdapter implements PolicyDecisionPort { private static final Logger log = LoggerFactory.getLogger(EmbeddedPolicyDecisionAdapter.class); private final List rules; + private final boolean strict; public EmbeddedPolicyDecisionAdapter(List rules) { + this(rules, false); + } + + /** Requires an explicit policy result and retains obligations for the enforcing caller. */ + public EmbeddedPolicyDecisionAdapter(List rules, boolean strict) { this.rules = rules == null ? List.of() : List.copyOf(rules); + this.strict = strict; } @Override public Mono authorize(SecurityPrincipal principal, String action, String resource, Map context) { if (rules.isEmpty()) { - return Mono.just(Decision.permit()); + return Mono.just(strict ? Decision.deny("no policy rules configured") : Decision.permit()); } return Flux.fromIterable(rules) - .concatMap(rule -> rule.evaluate(principal, action, resource, context) + .concatMap(rule -> evaluate(rule, principal, action, resource, context) .onErrorResume(ex -> { + if (strict) { + log.warn("Policy rule '{}' failed; denying access", rule.name()); + return Mono.just(Decision.deny("policy rule failed")); + } log.warn("Policy rule '{}' errored; treating as indeterminate: {}", rule.name(), ex.getMessage()); return Mono.just(Decision.indeterminate("rule error: " + rule.name())); })) @@ -64,11 +79,26 @@ public Mono authorize(SecurityPrincipal principal, String action, Stri .map(this::combine); } + private Mono evaluate(PolicyRule rule, SecurityPrincipal principal, + String action, String resource, Map context) { + if (!strict) { + return rule.evaluate(principal, action, resource, context); + } + return Mono.defer(() -> rule.evaluate(principal, action, resource, context)) + .defaultIfEmpty(Decision.deny("policy rule returned no decision")) + .map(decision -> decision.effect() == null + ? Decision.deny("policy rule returned an invalid decision") : decision); + } + private Decision combine(List decisions) { if (decisions.stream().anyMatch(d -> d.effect() == Decision.Effect.DENY)) { return Decision.deny("denied by policy"); } if (decisions.stream().anyMatch(Decision::granted)) { + if (strict) { + return Decision.permit(decisions.stream().filter(Decision::granted) + .flatMap(decision -> decision.obligations().stream()).toList()); + } return Decision.permit(); } return Decision.deny("no policy rule permitted access"); diff --git a/modules/security-core/src/test/java/org/fireflyframework/security/core/policy/StrictEmbeddedPolicyDecisionAdapterTest.java b/modules/security-core/src/test/java/org/fireflyframework/security/core/policy/StrictEmbeddedPolicyDecisionAdapterTest.java new file mode 100644 index 000000000..8c907bcbd --- /dev/null +++ b/modules/security-core/src/test/java/org/fireflyframework/security/core/policy/StrictEmbeddedPolicyDecisionAdapterTest.java @@ -0,0 +1,64 @@ +package org.fireflyframework.security.core.policy; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import org.fireflyframework.security.api.domain.*; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +class StrictEmbeddedPolicyDecisionAdapterTest { + private final SecurityPrincipal principal = SecurityPrincipal.builder().subject("synthetic-user").build(); + + private EmbeddedPolicyDecisionAdapter adapter(List rules) { + try { + return EmbeddedPolicyDecisionAdapter.class.getConstructor(List.class, boolean.class) + .newInstance(rules, true); + } catch (ReflectiveOperationException error) { + return fail("Explicit strict policy mode must be available without changing the legacy constructor", error); + } + } + + private Decision decide(List rules) { + return adapter(rules).authorize(principal, "read", "synthetic-resource", Map.of()).block(); + } + + @Test void missingRulesDenyRatherThanDeferToAnAbsentRbacGate() { + assertFalse(decide(List.of()).granted()); + } + @Test void anExplicitPermitStillWorksAndNormalAbstentionIsAllowed() { + assertTrue(decide(List.of((p,a,r,c)->Mono.just(Decision.indeterminate("not-applicable")), + (p,a,r,c)->Mono.just(Decision.permit()))).granted()); + } + @Test void denyOverridesAnotherPermit() { + assertFalse(decide(List.of((p,a,r,c)->Mono.just(Decision.permit()), + (p,a,r,c)->Mono.just(Decision.deny("blocked")))).granted()); + } + @Test void asynchronousRuleFailureCannotHideBehindAnotherPermit() { + assertFalse(decide(List.of((p,a,r,c)->Mono.just(Decision.permit()), + (p,a,r,c)->Mono.error(new IllegalStateException("synthetic-private-detail")))).granted()); + } + @Test void synchronousRuleFailureCannotHideBehindAnotherPermit() { + assertFalse(decide(List.of((p,a,r,c)->{throw new IllegalStateException("synthetic-private-detail");}, + (p,a,r,c)->Mono.just(Decision.permit()))).granted()); + } + @Test void emptyRulePublisherCannotHideBehindAnotherPermit() { + assertFalse(decide(List.of((p,a,r,c)->Mono.empty(), + (p,a,r,c)->Mono.just(Decision.permit()))).granted()); + } + @Test void malformedDecisionEffectCannotHideBehindAnotherPermitInEitherOrder() { + PolicyRule malformed = (p,a,r,c) -> Mono.just(new Decision(null,List.of(),"missing effect")); + PolicyRule permit = (p,a,r,c) -> Mono.just(Decision.permit()); + assertFalse(decide(List.of(malformed,permit)).granted()); + assertFalse(decide(List.of(permit,malformed)).granted()); + } + @Test void permittedObligationsAreRetainedInRuleOrder() { + var first=Obligation.of("row-filter",Map.of("tenant","synthetic-tenant")); + var second=Obligation.of("mask",Map.of("field","synthetic-field")); + var decision=decide(List.of((p,a,r,c)->Mono.just(Decision.permit(List.of(first))), + (p,a,r,c)->Mono.just(Decision.permit(List.of(second))))); + assertTrue(decision.granted());assertEquals(List.of(first,second),decision.obligations()); + assertThrows(UnsupportedOperationException.class,()->decision.obligations().clear()); + } +} diff --git a/modules/security-idp-aws-cognito/README.md b/modules/security-idp-aws-cognito/README.md index f73a9ca57..eba7388ee 100644 --- a/modules/security-idp-aws-cognito/README.md +++ b/modules/security-idp-aws-cognito/README.md @@ -20,7 +20,7 @@ Use an existing Cognito user pool for applicant identities. Configure its region org.fireflyframework fireflyframework-security-idp-aws-cognito - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-idp-aws-cognito/pom.xml b/modules/security-idp-aws-cognito/pom.xml index d9a4038ae..76c2dc82d 100644 --- a/modules/security-idp-aws-cognito/pom.xml +++ b/modules/security-idp-aws-cognito/pom.xml @@ -7,19 +7,19 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-idp-aws-cognito - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - IDP - AWS Cognito Implementation AWS Cognito implementation of the Firefly IDP Adapter - 26.10.21-SNAPSHOT + 26.10.22 2.41.26 diff --git a/modules/security-idp-azure-ad/README.md b/modules/security-idp-azure-ad/README.md index ae2e8ba1c..d843c984c 100644 --- a/modules/security-idp-azure-ad/README.md +++ b/modules/security-idp-azure-ad/README.md @@ -20,7 +20,7 @@ Connect applicant or reviewer identity operations to the configured Entra ID or org.fireflyframework fireflyframework-security-idp-azure-ad - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-idp-azure-ad/pom.xml b/modules/security-idp-azure-ad/pom.xml index b112f62ca..fb2032d8f 100644 --- a/modules/security-idp-azure-ad/pom.xml +++ b/modules/security-idp-azure-ad/pom.xml @@ -7,19 +7,19 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-idp-azure-ad - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - IDP - Azure AD Implementation Azure AD (Entra ID and B2C) implementation of the Firefly IDP Adapter - 26.10.21-SNAPSHOT + 26.10.22 6.23.0 1.23.1 diff --git a/modules/security-idp-internal-db/README.md b/modules/security-idp-internal-db/README.md index e86e85ece..2312da5ce 100644 --- a/modules/security-idp-internal-db/README.md +++ b/modules/security-idp-internal-db/README.md @@ -20,7 +20,7 @@ Use the internal database adapter when this service owns its identity store. Con org.fireflyframework fireflyframework-security-idp-internal-db - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-idp-internal-db/pom.xml b/modules/security-idp-internal-db/pom.xml index 39947b8bd..93bbdbbd5 100644 --- a/modules/security-idp-internal-db/pom.xml +++ b/modules/security-idp-internal-db/pom.xml @@ -7,19 +7,19 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-idp-internal-db - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - IDP - Internal Database Implementation Internal database-based implementation of the Firefly IDP Adapter using R2DBC - 26.10.21-SNAPSHOT + 26.10.22 0.13.0 diff --git a/modules/security-idp-keycloak/README.md b/modules/security-idp-keycloak/README.md index 2227f431e..244b35e3a 100644 --- a/modules/security-idp-keycloak/README.md +++ b/modules/security-idp-keycloak/README.md @@ -20,7 +20,7 @@ Manage applicant identities in the configured Keycloak realm. Set its server and org.fireflyframework fireflyframework-security-idp-keycloak - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-idp-keycloak/pom.xml b/modules/security-idp-keycloak/pom.xml index 3dc12dcc9..b7263aa27 100644 --- a/modules/security-idp-keycloak/pom.xml +++ b/modules/security-idp-keycloak/pom.xml @@ -7,19 +7,19 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-idp-keycloak - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - IDP Keycloak Keycloak identity provider integration for the Firefly Framework - 26.10.21-SNAPSHOT + 26.10.22 1.6.3 26.0.8 diff --git a/modules/security-idp/README.md b/modules/security-idp/README.md index 217f2603a..d72375e29 100644 --- a/modules/security-idp/README.md +++ b/modules/security-idp/README.md @@ -20,7 +20,7 @@ Manage applicant identities through IdpAdapter and a selected provider. Keep ide org.fireflyframework fireflyframework-security-idp - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-idp/pom.xml b/modules/security-idp/pom.xml index 912988958..d41cf5206 100644 --- a/modules/security-idp/pom.xml +++ b/modules/security-idp/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-idp - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Identity Provider diff --git a/modules/security-method-policy/README.md b/modules/security-method-policy/README.md index 7ebe43cc9..647b98816 100644 --- a/modules/security-method-policy/README.md +++ b/modules/security-method-policy/README.md @@ -20,7 +20,7 @@ Protect a reactive review method on a Spring bean with the required reviewer aut org.fireflyframework fireflyframework-security-method-policy - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-method-policy/pom.xml b/modules/security-method-policy/pom.xml index 58c2f5ed9..8a367ce01 100644 --- a/modules/security-method-policy/pom.xml +++ b/modules/security-method-policy/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-method-policy - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security Method Policy diff --git a/modules/security-oauth2-client/README.md b/modules/security-oauth2-client/README.md index c346adbf9..b8bf3ef91 100644 --- a/modules/security-oauth2-client/README.md +++ b/modules/security-oauth2-client/README.md @@ -20,7 +20,7 @@ The onboarding service may need an authorized outbound call to a protected partn org.fireflyframework fireflyframework-security-oauth2-client - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-oauth2-client/pom.xml b/modules/security-oauth2-client/pom.xml index b5538b7f4..9917eb8e4 100644 --- a/modules/security-oauth2-client/pom.xml +++ b/modules/security-oauth2-client/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-oauth2-client - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security OAuth2 Client @@ -43,6 +43,11 @@ + + org.springframework.boot + spring-boot-starter-oauth2-client + test + org.springframework.boot spring-boot-starter-webflux diff --git a/modules/security-oauth2-client/src/main/java/org/fireflyframework/security/client/OAuth2ClientAutoConfiguration.java b/modules/security-oauth2-client/src/main/java/org/fireflyframework/security/client/OAuth2ClientAutoConfiguration.java index 4541d46ba..28a1bf816 100644 --- a/modules/security-oauth2-client/src/main/java/org/fireflyframework/security/client/OAuth2ClientAutoConfiguration.java +++ b/modules/security-oauth2-client/src/main/java/org/fireflyframework/security/client/OAuth2ClientAutoConfiguration.java @@ -36,7 +36,7 @@ * {@code authorization_code}, {@code refresh_token} and {@code client_credentials}, and a WebClient * token-relay filter. All beans are {@link ConditionalOnMissingBean} so applications can override them. */ -@AutoConfiguration +@AutoConfiguration(afterName = "org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientAutoConfiguration") @ConditionalOnClass(ReactiveOAuth2AuthorizedClientManager.class) public class OAuth2ClientAutoConfiguration { diff --git a/modules/security-oauth2-client/src/test/java/org/fireflyframework/security/client/OAuth2ClientAutoConfigurationTest.java b/modules/security-oauth2-client/src/test/java/org/fireflyframework/security/client/OAuth2ClientAutoConfigurationTest.java new file mode 100644 index 000000000..80be296a8 --- /dev/null +++ b/modules/security-oauth2-client/src/test/java/org/fireflyframework/security/client/OAuth2ClientAutoConfigurationTest.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.security.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction; + +class OAuth2ClientAutoConfigurationTest { + private ReactiveWebApplicationContextRunner runner() { + return new ReactiveWebApplicationContextRunner().withConfiguration(AutoConfigurations.of( + ReactiveOAuth2ClientAutoConfiguration.class, OAuth2ClientAutoConfiguration.class)); + } + + private String[] registration(int port) { + return new String[] { + "spring.security.oauth2.client.registration.worker.provider=fixture", + "spring.security.oauth2.client.registration.worker.client-id=synthetic-worker", + "spring.security.oauth2.client.registration.worker.client-secret=synthetic-private-secret", + "spring.security.oauth2.client.registration.worker.authorization-grant-type=client_credentials", + "spring.security.oauth2.client.registration.worker.scope=funds:read", + "spring.security.oauth2.client.provider.fixture.token-uri=http://127.0.0.1:" + port + "/oauth2/token"}; + } + + @Test + void nativeBootRegistrationCreatesBackgroundManagerAndActuallyAcquiresToken() { + var server = new WireMockServer(options().bindAddress("127.0.0.1").dynamicPort()); + server.start(); + try { + server.stubFor(post(urlEqualTo("/oauth2/token")).willReturn(okJson( + "{\"access_token\":\"synthetic-client-token\",\"token_type\":\"Bearer\",\"expires_in\":30}"))); + runner().withPropertyValues(registration(server.port())).run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ReactiveClientRegistrationRepository.class); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientService.class); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class); + assertThat(context).hasSingleBean(ServerOAuth2AuthorizedClientExchangeFilterFunction.class); + var manager = context.getBean(ReactiveOAuth2AuthorizedClientManager.class); + assertThat(manager).isInstanceOf(AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager.class); + var request = OAuth2AuthorizeRequest.withClientRegistrationId("worker") + .principal("synthetic-tenant-worker").build(); + var authorized = manager.authorize(request).block(Duration.ofSeconds(5)); + assertThat(authorized).isNotNull(); + assertThat(authorized.getAccessToken().getTokenValue()).isEqualTo("synthetic-client-token"); + assertThat(authorized.getPrincipalName()).isEqualTo("synthetic-tenant-worker"); + }); + server.verify(postRequestedFor(urlEqualTo("/oauth2/token")) + .withRequestBody(containing("grant_type=client_credentials")) + .withRequestBody(containing("scope=funds%3Aread"))); + } finally { + server.stop(); + } + } + + @Test + void noRegistrationDoesNotInventClientIdentityOrManager() { + runner().run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(ReactiveClientRegistrationRepository.class); + assertThat(context).doesNotHaveBean(ReactiveOAuth2AuthorizedClientManager.class); + assertThat(context).doesNotHaveBean(ServerOAuth2AuthorizedClientExchangeFilterFunction.class); + }); + } + + @Test + void projectManagerAndClientServiceRemainSelectedWithoutPrimary() { + var manager = mock(ReactiveOAuth2AuthorizedClientManager.class); + var service = mock(ReactiveOAuth2AuthorizedClientService.class); + runner().withPropertyValues(registration(1)) + .withBean(ReactiveOAuth2AuthorizedClientManager.class, () -> manager) + .withBean(ReactiveOAuth2AuthorizedClientService.class, () -> service) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientService.class); + assertThat(context.getBean(ReactiveOAuth2AuthorizedClientManager.class)).isSameAs(manager); + assertThat(context.getBean(ReactiveOAuth2AuthorizedClientService.class)).isSameAs(service); + assertThat(context).hasSingleBean(ServerOAuth2AuthorizedClientExchangeFilterFunction.class); + }); + } +} diff --git a/modules/security-resource-server/README.md b/modules/security-resource-server/README.md index 2e5d24fda..7e24fa5c0 100644 --- a/modules/security-resource-server/README.md +++ b/modules/security-resource-server/README.md @@ -20,7 +20,7 @@ Protect onboarding HTTP routes with JWTs from the configured identity authority. org.fireflyframework fireflyframework-security-resource-server - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-resource-server/docs/access-token-validation.md b/modules/security-resource-server/docs/access-token-validation.md new file mode 100644 index 000000000..66c067e94 --- /dev/null +++ b/modules/security-resource-server/docs/access-token-validation.md @@ -0,0 +1,23 @@ +# Access-token claim validation + +`AccessTokenValidators` provides reusable claim-validation profiles for JWT access tokens. Install a profile on a signature-verifying decoder; the helper itself does not select keys, verify signatures or configure the security filter chain. + +```java +import org.fireflyframework.security.rs.AccessTokenValidators; + +// Require exp and retain Spring's default timestamp checks and clock skew. +decoder.setJwtValidator(AccessTokenValidators.timestamps()); + +// Also require an exact issuer and membership of one exact audience. +decoder.setJwtValidator(AccessTokenValidators.forIssuerAndAudience(issuer, audience)); +``` + +Both factories return `OAuth2TokenValidator`. Each composition includes a fresh Spring `JwtTimestampValidator`, preserving its default clock/skew checks for present `exp` and `nbf`, and adds an explicit required-exp check. A signed access token without expiration now fails with `invalid_token`. No new issued-at, not-before presence, token-age or maximum-lifetime requirement is introduced. + +`forIssuerAndAudience` rejects null/blank issuer or audience configuration immediately. The existing Spring issuer validator compares the exact configured issuer. Audience matching is exact membership: a token may contain additional audiences, but must include the configured audience. Values are not trimmed, case-folded, normalized, wildcard-matched or replaced with defaults. Error descriptions do not include token values or claims. + +The framework's default `ResourceServerAutoConfiguration` uses `timestamps()`. This intentionally closes the previous acceptance of tokens with missing `exp`. Its optional issuer setting and optional acceptable-audience list keep their existing behavior: an absent issuer setting does not create an issuer requirement, and a nonempty audience list accepts any configured audience. Remote-JWKS/in-memory decoder selection, signature algorithm allowlists, authority mapping, policy decisions and default security chain are unchanged. + +Applications supplying their own decoder/validator must opt into the helper; overriding the decoder also overrides the framework's validation configuration. Four case-service applications adopt the composed helper in a separate consumer gate. Authentication still requires the deployment's trusted signing keys and issuer/audience configuration. + +Qualification uses pure validator contracts plus the existing real RANDOM_PORT resource-server integration fixture with signed RSA tokens. The missing-exp test reproduces the previous200 response and now requires401; the existing valid signed token still returns200. Expired/future-not-before claims, default skew, issuer/audience membership and blank configuration are covered separately. This module's verification is not a claim that a later full-framework gate or external identity-provider acceptance has completed. diff --git a/modules/security-resource-server/pom.xml b/modules/security-resource-server/pom.xml index 67f29d236..fdbae12ca 100644 --- a/modules/security-resource-server/pom.xml +++ b/modules/security-resource-server/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-resource-server - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security Resource Server diff --git a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/AccessTokenValidators.java b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/AccessTokenValidators.java new file mode 100644 index 000000000..03b8e7dde --- /dev/null +++ b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/AccessTokenValidators.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.security.rs; + +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.util.Assert; + +/** + * Access-token claim validators for signature-verifying JWT decoders. Unlike Spring's timestamp + * validator alone, these profiles require an expiration claim; Spring's default clock skew and + * present-expiration/not-before checks are retained. These factories do not verify signatures. + */ +public final class AccessTokenValidators { + + private static final OAuth2Error REQUIRED_EXPIRATION = + new OAuth2Error("invalid_token", "Access token expiration is required", null); + + private AccessTokenValidators() { + } + + /** Requires expiration and applies Spring's existing default timestamp validation. */ + public static OAuth2TokenValidator timestamps() { + OAuth2TokenValidator requiredExpiration = jwt -> jwt.getExpiresAt() == null + ? OAuth2TokenValidatorResult.failure(REQUIRED_EXPIRATION) + : OAuth2TokenValidatorResult.success(); + return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), requiredExpiration); + } + + /** + * Requires expiration, a case-sensitive exact issuer and membership of the exact expected + * audience. Configuration values must be nonblank; values are not normalized or trimmed. + */ + public static OAuth2TokenValidator forIssuerAndAudience(String issuer, String audience) { + Assert.hasText(issuer, "Issuer must not be blank"); + Assert.hasText(audience, "Audience must not be blank"); + OAuth2TokenValidator expectedAudience = jwt -> jwt.getAudience() != null + && jwt.getAudience().contains(audience) + ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure( + new OAuth2Error("invalid_token", "Required audience is missing", null)); + return new DelegatingOAuth2TokenValidator<>(timestamps(), new JwtIssuerValidator(issuer), expectedAudience); + } +} diff --git a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java index 7f10dfdd0..6dbf01ea8 100644 --- a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java +++ b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java @@ -17,6 +17,7 @@ package org.fireflyframework.security.rs; import org.fireflyframework.security.api.domain.SigningKey; +import org.fireflyframework.security.api.domain.Decision; import org.fireflyframework.security.core.authority.AuthorityMappingProperties; import org.fireflyframework.security.core.authority.ConfigurableAuthorityMapper; import org.fireflyframework.security.core.audit.LoggingAuditEventAdapter; @@ -50,7 +51,6 @@ import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; -import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.web.server.SecurityWebFilterChain; @@ -97,8 +97,14 @@ public PrincipalFactory fireflyPrincipalFactory(AuthorityMappingPort authorityMa @Bean @ConditionalOnMissingBean + public PolicyDecisionPort fireflyPolicyDecisionPort(ObjectProvider rules, + ResourceServerProperties properties) { + return new EmbeddedPolicyDecisionAdapter(rules.orderedStream().toList(), properties.isStrictPolicy()); + } + + /** Preserves direct factory callers that explicitly rely on the legacy RBAC deferral. */ public PolicyDecisionPort fireflyPolicyDecisionPort(ObjectProvider rules) { - return new EmbeddedPolicyDecisionAdapter(rules.orderedStream().toList()); + return fireflyPolicyDecisionPort(rules, new ResourceServerProperties()); } @Bean @@ -151,7 +157,7 @@ static NimbusReactiveJwtDecoder buildDecoder(KeyManagementPort keyManagementPort private static List> buildValidators(ResourceServerProperties properties) { List> validators = new ArrayList<>(); - validators.add(new JwtTimestampValidator()); + validators.add(AccessTokenValidators.timestamps()); if (properties.getIssuer() != null && !properties.getIssuer().isBlank()) { validators.add(new JwtIssuerValidator(properties.getIssuer())); } @@ -170,7 +176,14 @@ public SecurityWebFilterChain fireflySecurityWebFilterChain( PolicyDecisionPort policyDecisionPort, ResourceServerProperties properties) { - PolicyAuthorizationManager policyManager = new PolicyAuthorizationManager(policyDecisionPort); + PolicyDecisionPort urlPolicy = properties.isStrictPolicy() + ? (principal, action, resource, context) -> policyDecisionPort + .authorize(principal, action, resource, context) + .timeout(Duration.ofSeconds(5)) + .map(decision -> decision.obligations().isEmpty() ? decision + : Decision.deny("URL enforcement cannot fulfill policy obligations")) + : policyDecisionPort; + PolicyAuthorizationManager policyManager = new PolicyAuthorizationManager(urlPolicy); http .csrf(ServerHttpSecurity.CsrfSpec::disable) diff --git a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java index 82e3457d4..38fbbc3f0 100644 --- a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java +++ b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java @@ -34,6 +34,13 @@ public class ResourceServerProperties { /** Ant-style path patterns that are publicly accessible without authentication. */ private List permitMatchers = new ArrayList<>(); + /** + * Requires explicit configured policy decisions in the default embedded provider. Empty rules, + * empty results, and failed rules deny; permit obligations remain for the enforcing caller. + * False retains the legacy no-rules deferral to a separate RBAC gate. + */ + private boolean strictPolicy; + /** Expected token issuer ({@code iss}); when set, an issuer validator is enforced. */ private String issuer; diff --git a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/AccessTokenValidatorsTest.java b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/AccessTokenValidatorsTest.java new file mode 100644 index 000000000..0f5319a95 --- /dev/null +++ b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/AccessTokenValidatorsTest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.security.rs; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class AccessTokenValidatorsTest { + + private Jwt.Builder token() { + return Jwt.withTokenValue("synthetic-token").header("alg", "RS256") + .subject("synthetic-subject").issuedAt(Instant.now().minusSeconds(7200)); + } + + @Test + void timestampsRequireExpirationWithoutLeakingTokenClaims() { + var result = AccessTokenValidators.timestamps().validate(token().build()); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).allSatisfy(error -> { + assertThat(error.getErrorCode()).isEqualTo("invalid_token"); + assertThat(error.getDescription()).doesNotContain("synthetic-subject", "synthetic-token"); + }); + } + + @Test + void validTimestampsDoNotIntroduceIssuerOrAudienceRequirements() { + var jwt = token().expiresAt(Instant.now().plusSeconds(300)) + .notBefore(Instant.now().minusSeconds(1)).build(); + assertThat(AccessTokenValidators.timestamps().validate(jwt).hasErrors()).isFalse(); + } + + @Test + void expiredTokenIsRejected() { + var jwt = token().expiresAt(Instant.now().minusSeconds(3600)).build(); + assertThat(AccessTokenValidators.timestamps().validate(jwt).hasErrors()).isTrue(); + } + + @Test + void futureNotBeforeIsRejected() { + var jwt = token().expiresAt(Instant.now().plusSeconds(7200)) + .notBefore(Instant.now().plusSeconds(3600)).build(); + assertThat(AccessTokenValidators.timestamps().validate(jwt).hasErrors()).isTrue(); + } + + @Test + void springDefaultClockSkewIsPreservedForExpirationAndNotBefore() { + assertThat(AccessTokenValidators.timestamps().validate( + token().expiresAt(Instant.now().minusSeconds(30)).build()).hasErrors()).isFalse(); + assertThat(AccessTokenValidators.timestamps().validate(token().expiresAt(Instant.now().plusSeconds(300)) + .notBefore(Instant.now().plusSeconds(30)).build()).hasErrors()).isFalse(); + } + + @Test + void exactIssuerAndAudienceMembershipAcceptMultipleAudiences() { + var jwt = token().expiresAt(Instant.now().plusSeconds(300)).issuer("https://issuer.test") + .audience(List.of("unrelated-api", "document-api")).build(); + assertThat(AccessTokenValidators.forIssuerAndAudience("https://issuer.test", "document-api") + .validate(jwt).hasErrors()).isFalse(); + } + + @Test + void issuerAndAudienceDoNotUseCaseFoldingOrSuffixAliases() { + for (var jwt : List.of( + token().expiresAt(Instant.now().plusSeconds(300)).issuer("https://issuer.test/").audience(List.of("document-api")).build(), + token().expiresAt(Instant.now().plusSeconds(300)).issuer("https://issuer.test").audience(List.of("DOCUMENT-API")).build(), + token().expiresAt(Instant.now().plusSeconds(300)).issuer("https://issuer.test").build(), + token().expiresAt(Instant.now().plusSeconds(300)).audience(List.of("document-api")).build())) { + assertThat(AccessTokenValidators.forIssuerAndAudience("https://issuer.test", "document-api") + .validate(jwt).hasErrors()).isTrue(); + } + } + + @Test + void issuerAndAudienceCompositionStillRequiresExpiration() { + var jwt = token().issuer("https://issuer.test").audience(List.of("document-api")).build(); + assertThat(AccessTokenValidators.forIssuerAndAudience("https://issuer.test", "document-api") + .validate(jwt).hasErrors()).isTrue(); + } + + @Test + void nullAndBlankConfigurationRejectImmediately() { + for (String blank : new String[]{null, "", " ", "\t"}) { + assertThatIllegalArgumentException().isThrownBy(() -> + AccessTokenValidators.forIssuerAndAudience(blank, "document-api")); + assertThatIllegalArgumentException().isThrownBy(() -> + AccessTokenValidators.forIssuerAndAudience("https://issuer.test", blank)); + } + } +} diff --git a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/ResourceServerIntegrationTest.java b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/ResourceServerIntegrationTest.java index 006c99904..86fb9bc52 100644 --- a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/ResourceServerIntegrationTest.java +++ b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/ResourceServerIntegrationTest.java @@ -112,6 +112,16 @@ void protectedRouteWithValidTokenIsAuthorized() throws Exception { .expectBody(String.class).isEqualTo("alice"); } + @Test + void signedTokenWithoutExpirationIsUnauthorized() throws Exception { + JWTClaimsSet claims = validClaims().expirationTime(null) + .notBeforeTime(Date.from(Instant.now().minusSeconds(1))).build(); + assertThat(claims.getExpirationTime()).isNull(); + String token = signWith(keyManagementPort.activeSigningKey().block(), claims); + client.get().uri("/api/me").header("Authorization", "Bearer " + token) + .exchange().expectStatus().isUnauthorized(); + } + @Test void expiredTokenIsUnauthorized() throws Exception { Instant past = Instant.now().minusSeconds(3600); diff --git a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/StrictPolicyConfigurationTest.java b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/StrictPolicyConfigurationTest.java new file mode 100644 index 000000000..442eb461e --- /dev/null +++ b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/StrictPolicyConfigurationTest.java @@ -0,0 +1,78 @@ +package org.fireflyframework.security.rs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.fireflyframework.security.api.domain.*; +import org.fireflyframework.security.core.policy.PolicyRule; +import org.fireflyframework.security.spi.PolicyDecisionPort; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import reactor.core.publisher.Mono; + +class StrictPolicyConfigurationTest { + private final SecurityPrincipal principal = SecurityPrincipal.builder().subject("synthetic-user").build(); + private final ReactiveWebApplicationContextRunner runner = new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ResourceServerAutoConfiguration.class)) + .withUserConfiguration(Decoder.class); + + @Test void theOriginalDirectFactorySignatureRemainsAvailable() { + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> + ResourceServerAutoConfiguration.class.getMethod("fireflyPolicyDecisionPort", + org.springframework.beans.factory.ObjectProvider.class)); + } + @Test void legacyFallbackStillDefersToRbacByDefault() { + runner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(PolicyDecisionPort.class) + .authorize(principal, "read", "synthetic-resource", Map.of()).block().granted()).isTrue(); + }); + } + @Test void explicitStrictPropertyMakesTheDefaultProviderDenyMissingRules() { + runner.withPropertyValues("firefly.security.resource-server.strict-policy=true").run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(PolicyDecisionPort.class); + assertThat(context.getBean(PolicyDecisionPort.class) + .authorize(principal, "read", "synthetic-resource", Map.of()).block().granted()).isFalse(); + }); + } + @Test void strictFallbackRetainsPolicyObligationsForTheEnforcingCaller() { + runner.withPropertyValues("firefly.security.resource-server.strict-policy=true") + .withUserConfiguration(RequiredMask.class).run(context -> { + assertThat(context).hasNotFailed(); + var decision=context.getBean(PolicyDecisionPort.class) + .authorize(principal,"read","synthetic-resource",Map.of()).block(); + assertThat(decision.granted()).isTrue(); + assertThat(decision.obligations()).containsExactly(RequiredMask.MASK); + }); + } + @Test void aPlainProjectProviderRemainsTheSingleAuthority() { + for(boolean reverse : new boolean[]{false,true}) { + var classes=reverse?new Class[]{RequiredMask.class, ProjectProvider.class} + : new Class[]{ProjectProvider.class, RequiredMask.class}; + runner.withPropertyValues("firefly.security.resource-server.strict-policy=true") + .withUserConfiguration(classes).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(PolicyDecisionPort.class); + assertThat(context.getBean(PolicyDecisionPort.class)).isSameAs(ProjectProvider.PROVIDER); + }); + } + } + @Configuration(proxyBeanMethods=false) static class Decoder { + @Bean ReactiveJwtDecoder syntheticDecoder() { + return token -> Mono.error(new BadJwtException("Synthetic decoder is not used by these provider tests")); + } + } + @Configuration(proxyBeanMethods=false) static class RequiredMask { + static final Obligation MASK=Obligation.of("mask",Map.of("field","synthetic-field")); + @Bean PolicyRule requireMask() {return (p,a,r,c)->Mono.just(Decision.permit(List.of(MASK)));} + } + @Configuration(proxyBeanMethods=false) static class ProjectProvider { + static final PolicyDecisionPort PROVIDER=(p,a,r,c)->Mono.just(Decision.deny("project-denial")); + @Bean PolicyDecisionPort projectPolicy() {return PROVIDER;} + } +} diff --git a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/StrictPolicyHttpIntegrationTest.java b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/StrictPolicyHttpIntegrationTest.java new file mode 100644 index 000000000..54b03aaf3 --- /dev/null +++ b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/StrictPolicyHttpIntegrationTest.java @@ -0,0 +1,73 @@ +package org.fireflyframework.security.rs; + +import static org.junit.jupiter.api.Assertions.*; + +import com.nimbusds.jose.*; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.*; +import java.net.URI; +import java.net.http.*; +import java.security.interfaces.RSAPrivateKey; +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.fireflyframework.security.api.domain.*; +import org.fireflyframework.security.core.policy.PolicyRule; +import org.fireflyframework.security.spi.KeyManagementPort; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +@SpringBootTest(classes=StrictPolicyHttpIntegrationTest.Application.class, + webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT, + properties={"server.address=127.0.0.1","firefly.security.resource-server.strict-policy=true"}) +class StrictPolicyHttpIntegrationTest { + static final AtomicInteger EFFECTS=new AtomicInteger(); + @LocalServerPort int port; + @Autowired KeyManagementPort keys; + @BeforeEach void reset(){EFFECTS.set(0);} + + @SpringBootConfiguration @EnableAutoConfiguration + static class Application { + @Bean PolicyRule currentPolicy() { + return (p,a,r,c)->switch(r) { + case "/api/masked" -> Mono.just(Decision.permit(List.of(Obligation.of("mask",Map.of("field","synthetic-field"))))); + case "/api/never" -> Mono.never(); + default -> Mono.just(Decision.permit()); + }; + } + @RestController static class Controller { + @GetMapping({"/api/plain","/api/masked","/api/never"}) String get(){EFFECTS.incrementAndGet();return "synthetic-result";} + } + } + @Test void aPlainExplicitPermitStillReachesTheController() throws Exception { + assertEquals(200,send("/api/plain",true).statusCode());assertEquals(1,EFFECTS.get()); + } + @Test void missingTokenNeverReachesCurrentPolicyProtectedData() throws Exception { + assertEquals(401,send("/api/plain",false).statusCode());assertEquals(0,EFFECTS.get()); + } + @Test void anUnsupportedObligationCannotDisappearAtTheUrlEnforcementBoundary() throws Exception { + var response=send("/api/masked",true); + assertEquals(403,response.statusCode(),response.body());assertEquals(0,EFFECTS.get()); + } + @Test void aNonCompletingPolicyIsBoundedAndCannotReachTheController() { + var response=assertTimeoutPreemptively(Duration.ofSeconds(8),()->send("/api/never",true)); + assertEquals(403,response.statusCode(),response.body());assertEquals(0,EFFECTS.get()); + } + HttpResponse send(String path,boolean authenticated) throws Exception { + var builder=HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+path)).GET(); + if(authenticated){ + var key=keys.activeSigningKey().block(); + var claims=new JWTClaimsSet.Builder().subject("synthetic-staff").expirationTime(Date.from(Instant.now().plusSeconds(120))).build(); + var jwt=new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.kid()).build(),claims); + jwt.sign(new RSASSASigner((RSAPrivateKey)key.privateKey()));builder.header("Authorization","Bearer "+jwt.serialize()); + } + return HttpClient.newHttpClient().send(builder.build(),HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/modules/security-spi/README.md b/modules/security-spi/README.md index 4df9eeee6..eb9a3c038 100644 --- a/modules/security-spi/README.md +++ b/modules/security-spi/README.md @@ -20,7 +20,7 @@ Connect the onboarding service's policy, relationship, revocation, key, or secre org.fireflyframework fireflyframework-security-spi - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-spi/pom.xml b/modules/security-spi/pom.xml index e675a2a44..ef6a31955 100644 --- a/modules/security-spi/pom.xml +++ b/modules/security-spi/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-spi - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security SPI diff --git a/modules/security-test/README.md b/modules/security-test/README.md index 63b7e42ab..e9378f4a1 100644 --- a/modules/security-test/README.md +++ b/modules/security-test/README.md @@ -20,7 +20,7 @@ Create applicant/reviewer test principals and deterministic policy outcomes for org.fireflyframework fireflyframework-security-test - 26.10.21-SNAPSHOT + 26.10.22 test ``` diff --git a/modules/security-test/pom.xml b/modules/security-test/pom.xml index 23f29ce9a..8bc202382 100644 --- a/modules/security-test/pom.xml +++ b/modules/security-test/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-test - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security Test diff --git a/modules/security-webflux/README.md b/modules/security-webflux/README.md index 00aa7b61a..ff72ad32a 100644 --- a/modules/security-webflux/README.md +++ b/modules/security-webflux/README.md @@ -20,7 +20,7 @@ Project the authenticated reviewer into the framework principal and connect URL org.fireflyframework fireflyframework-security-webflux - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/security-webflux/pom.xml b/modules/security-webflux/pom.xml index 55da1aa62..0411a397e 100644 --- a/modules/security-webflux/pom.xml +++ b/modules/security-webflux/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-security-webflux - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Security WebFlux Binding diff --git a/modules/starter-application/README.md b/modules/starter-application/README.md index e74016cd3..5c5dd8a6b 100644 --- a/modules/starter-application/README.md +++ b/modules/starter-application/README.md @@ -20,7 +20,7 @@ An application-tier onboarding API can use these request/context conventions whi org.fireflyframework fireflyframework-starter-application - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/starter-application/pom.xml b/modules/starter-application/pom.xml index 6683b87da..24ecda22b 100644 --- a/modules/starter-application/pom.xml +++ b/modules/starter-application/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-starter-application - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Starter Application diff --git a/modules/starter-core/README.md b/modules/starter-core/README.md index af4078d70..fb887414c 100644 --- a/modules/starter-core/README.md +++ b/modules/starter-core/README.md @@ -22,7 +22,7 @@ A larger onboarding service may adopt this bundle for shared HTTP-client and ope org.fireflyframework fireflyframework-starter-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/starter-core/pom.xml b/modules/starter-core/pom.xml index 22e932b79..a0f6475ca 100644 --- a/modules/starter-core/pom.xml +++ b/modules/starter-core/pom.xml @@ -8,12 +8,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-starter-core - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Starter Core diff --git a/modules/starter-data/README.md b/modules/starter-data/README.md index 0b15ceb44..1fdaa575c 100644 --- a/modules/starter-data/README.md +++ b/modules/starter-data/README.md @@ -20,7 +20,7 @@ Background document enrichment or verification jobs may need the data-job and pr org.fireflyframework fireflyframework-starter-data - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/starter-data/docs/README.md b/modules/starter-data/docs/README.md index ada38e557..814ecccbf 100644 --- a/modules/starter-data/docs/README.md +++ b/modules/starter-data/docs/README.md @@ -31,7 +31,7 @@ An opinionated Spring Boot starter for building data-processing microservices wi org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 @@ -39,7 +39,7 @@ An opinionated Spring Boot starter for building data-processing microservices wi org.fireflyframework fireflyframework-starter-data - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/starter-data/docs/data-jobs/guide.md b/modules/starter-data/docs/data-jobs/guide.md index b8a078ec0..93741b8b4 100644 --- a/modules/starter-data/docs/data-jobs/guide.md +++ b/modules/starter-data/docs/data-jobs/guide.md @@ -678,7 +678,7 @@ Add `fireflyframework-starter-data` to your `pom.xml`: org.fireflyframework fireflyframework-starter-data - 26.10.21-SNAPSHOT + 26.10.22 diff --git a/modules/starter-data/pom.xml b/modules/starter-data/pom.xml index f969418b0..09f06b7b6 100644 --- a/modules/starter-data/pom.xml +++ b/modules/starter-data/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-starter-data - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Starter Data diff --git a/modules/starter-domain/README.md b/modules/starter-domain/README.md index 1b457ed18..28186e921 100644 --- a/modules/starter-domain/README.md +++ b/modules/starter-domain/README.md @@ -20,7 +20,7 @@ Use this dependency bundle when application submission/review grows into a domai org.fireflyframework fireflyframework-starter-domain - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/starter-domain/pom.xml b/modules/starter-domain/pom.xml index 741e39517..1e82da655 100644 --- a/modules/starter-domain/pom.xml +++ b/modules/starter-domain/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-starter-domain - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Starter Domain diff --git a/modules/utils/README.md b/modules/utils/README.md index 06ae42eaf..f15bc5c94 100644 --- a/modules/utils/README.md +++ b/modules/utils/README.md @@ -20,7 +20,7 @@ After accepting an application, the service can render a confirmation template o org.fireflyframework fireflyframework-utils - 26.10.21-SNAPSHOT + 26.10.22 ``` @@ -28,6 +28,29 @@ After accepting an application, the service can render a confirmation template o The Java examples in this section are API fragments for application code. Use the stated imports and supply the application-owned collaborators described alongside them. +### Render a restricted published fragment + +Use `RestrictedHtmlTemplateRenderer` for the closed `RESTRICTED_XHTML_V1` profile. Its independent configuration accepts a deeply copied JSON-compatible model, exact BigInteger/BigDecimal and integral numbers; it rejects arbitrary Java/FreeMarker objects and floating-point values. Model strings remain data. Existing `TemplateRenderUtil` retains its application-owned classpath/file/PDF behavior. + +```java +import org.fireflyframework.utils.template.RestrictedHtmlTemplateRenderer; +import java.util.Map; + +var renderer = new RestrictedHtmlTemplateRenderer(); +var result = renderer.render("

    ${recipient}

    ", + Map.of("recipient", "Synthetic & Bank")); +if (!result.success()) { + throw new IllegalArgumentException(result.code()); +} +String fragment = result.html(); //

    Synthetic & Bank

    +``` + +The renderer forces HTML escaping, disables API/class construction and external template loaders, then validates a well-formed XHTML fragment with a closed print tag/attribute profile. Scripts, styles, links, images, custom/external entity declarations, DTDs and unknown markup reject the entire output. No partial document is returned. Successful output must contain visible text. Application-owned branding/shells and financial facts remain separate responsibilities; this profile does not render arbitrary CSS/assets or PDF. + +`RestrictedTemplateLimits.defaults()` bounds template256KiB, output1MiB, model5000nodes/depth32, strings65536UTF16 units,10000template elements and5000ms cooperative checks. Custom limits are immutable per instance. Codes are `UNSUPPORTED_TEMPLATE`, `UNSUPPORTED_MODEL`, `RENDER_LIMIT`, `EMPTY_OUTPUT`, `INVALID_HTML` and `RENDER_FAILED`; they do not disclose source/model fragments. Cooperative tracing cannot interrupt one expensive expression or establish a hard heap/RSS/CPU boundary. A service accepting published content must supply its separately qualified worker-process lifetime/heap controls when required. This utility alone does not provide that isolation. + +Read the [complete restricted profile](docs/restricted-html-templates.md) before selecting it for an issuance service. + ### Render a template to HTML Place `invoice.ftl` under `src/main/resources/templates/`: diff --git a/modules/utils/docs/restricted-html-templates.md b/modules/utils/docs/restricted-html-templates.md new file mode 100644 index 000000000..3c2b9222d --- /dev/null +++ b/modules/utils/docs/restricted-html-templates.md @@ -0,0 +1,25 @@ +# Restricted HTML fragments + +`RestrictedHtmlTemplateRenderer` is an independent, immutable FreeMarker configuration for published-document HTML fragments. It does not use or change `TemplateRenderUtil`'s global loaders, hooks, shared variables, asynchronous executor or PDF/image behavior. Notification templates retain their own engine. + +```java +var renderer = new RestrictedHtmlTemplateRenderer(); +var result = renderer.render("

    ${name}

    ", Map.of("name", "Synthetic & Bank")); +if (!result.success()) { + // Handle the sanitized result.code(); html is always null on failure. +} +``` + +The API accepts only a deeply copied JSON-compatible model: string-keyed maps, lists, strings, booleans, null, exact BigInteger/BigDecimal and integral Java values. Arbitrary objects, direct FreeMarker TemplateModel instances, dates/DOM objects and Float/Double values are rejected. Numeric plain expansion is limited to128 characters; Unicode must contain valid surrogate pairs. Model strings are data, never additional template source. + +The configuration uses SimpleObjectWrapper, blocks API/class construction, disables DOM/Jython wrapping, installs a loader that never resolves anything, and forces HTML auto-escaping. Classpath/file/URL imports/includes are unavailable. Each request gets its own processing environment and copied model. FreeMarker still evaluates ordinary template expressions, conditions, loops and local macros inside these capability restrictions. + +After rendering, a secure JDK XML parser checks the well-formed fragment with DTD/entities/XInclude/external DTD/schema access disabled. The closed, lowercase print-document tag profile includes text sections/headings, emphasis, tables and lists. Only class/id token attributes, bounded numeric spans and table scope values are permitted. Scripts/events, author CSS/style tags, links, images/embedded content, XML instructions/comments and unknown elements/attributes reject the entire result. No partial or automatically stripped document is returned. Fragments must contain visible text. A product adapter may add a trusted outer shell, typed branding and restrictive CSP after this validation; arbitrary CSS/assets/PDF require another explicitly qualified profile. + +`RestrictedTemplateLimits.defaults()` bounds source256KiB UTF-8, output1MiB UTF-8, model5000nodes/depth32/string65536UTF-16 units,10000 template elements and5000milliseconds of cooperative deadline checks. Numeric values and model nodes have separate guards. Output accounting counts actual UTF-8 bytes, including supplementary characters split across writer calls. XML output is additionally limited to20000nodes and the configured depth. A failure leaves an instance reusable for the next request. + +Result codes are `UNSUPPORTED_TEMPLATE`, `UNSUPPORTED_MODEL`, `RENDER_LIMIT`, `EMPTY_OUTPUT`, `INVALID_HTML` and `RENDER_FAILED`. They do not contain model values, source fragments or exception messages. `RestrictedTemplateResult` cannot hold successful content together with an error code, or failed partial content. + +The deadline and TemplateProcessingTracer check element-level work; they do not interrupt a single long-running expression, schema validation, native code or a JVM allocation. This utility is not a hard CPU/heap/RSS sandbox. A service accepting published template content must apply the separately qualified bounded worker-process lifecycle when it needs a wall-clock/heap isolation boundary. The governed document producer supplies that adapter; do not treat Reactor timeout alone as cancellation of template execution. + +Tests cover actual escaped financial rows and exact decimal/large-integer output, blocked Java/API/loaders, escaping suppression, active HTML/XML entities/styles, output/element budgets, cyclic models/number expansion/Unicode, recovery after failure and isolation from legacy global hooks. Module verification also runs the unchanged legacy utility suite. Process kill/reap, schema validation and document persistence are separate producer gates. diff --git a/modules/utils/pom.xml b/modules/utils/pom.xml index c01fcad42..7886f14d2 100644 --- a/modules/utils/pom.xml +++ b/modules/utils/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-utils - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Utils diff --git a/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedHtmlTemplateRenderer.java b/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedHtmlTemplateRenderer.java new file mode 100644 index 000000000..39ce380b5 --- /dev/null +++ b/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedHtmlTemplateRenderer.java @@ -0,0 +1,256 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.utils.template; + +import freemarker.cache.TemplateLoader; +import freemarker.core.HTMLOutputFormat; +import freemarker.core.TemplateClassResolver; +import freemarker.core.TemplateProcessingTracer; +import freemarker.core.Environment; +import freemarker.template.Configuration; +import freemarker.template.SimpleObjectWrapper; +import freemarker.template.Template; +import freemarker.template.TemplateExceptionHandler; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; +import org.xml.sax.SAXParseException; +import org.xml.sax.helpers.DefaultHandler; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.Reader; +import java.io.StringReader; +import java.io.Writer; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Isolated JSON-only FreeMarker configuration and closed XHTML fragment profile. No global legacy + * utility configuration is consulted. The caller must isolate expression-heavy work when a hard + * worker-lifetime limit is needed: element tracing cannot interrupt an expression's Java code. + */ +public final class RestrictedHtmlTemplateRenderer { + public static final String PROFILE = "RESTRICTED_XHTML_V1"; + private static final Set TAGS = Set.of("section", "article", "main", "header", "footer", "div", "span", + "h1", "h2", "h3", "h4", "h5", "h6", "p", "strong", "em", "b", "i", "u", "small", "sub", "sup", + "table", "caption", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup", "col", "ul", "ol", "li", + "dl", "dt", "dd", "br", "hr", "blockquote", "pre", "code"); + private final RestrictedTemplateLimits limits; + private final Configuration configuration; + + public RestrictedHtmlTemplateRenderer() { + this(RestrictedTemplateLimits.defaults()); + } + + public RestrictedHtmlTemplateRenderer(RestrictedTemplateLimits limits) { + this.limits = java.util.Objects.requireNonNull(limits); + var cfg = new Configuration(Configuration.VERSION_2_3_35); + cfg.setDefaultEncoding("UTF-8"); + cfg.setLocale(Locale.US); + cfg.setNumberFormat("computer"); + cfg.setBooleanFormat("c"); + cfg.setOutputFormat(HTMLOutputFormat.INSTANCE); + cfg.setAutoEscapingPolicy(Configuration.FORCE_AUTO_ESCAPING_POLICY); + cfg.setAPIBuiltinEnabled(false); + cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER); + var wrapper = new SimpleObjectWrapper(Configuration.VERSION_2_3_35); + wrapper.setDOMNodeSupport(false); + wrapper.setJythonSupport(false); + wrapper.writeProtect(); + cfg.setObjectWrapper(wrapper); + cfg.setTemplateLoader(new TemplateLoader() { + public Object findTemplateSource(String name) { return null; } + public long getLastModified(Object source) { return -1; } + public Reader getReader(Object source, String encoding) { throw new IllegalStateException("Template loading is disabled"); } + public void closeTemplateSource(Object source) { } + }); + cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + cfg.setLogTemplateExceptions(false); + cfg.setWrapUncheckedExceptions(true); + cfg.setFallbackOnNullLoopVariable(false); + cfg.setLocalizedLookup(false); + this.configuration = cfg; + } + + public RestrictedTemplateResult render(String source, Map model) { + try { + if (source == null || source.isBlank() || !unicode(source) + || source.getBytes(StandardCharsets.UTF_8).length > limits.templateBytes()) { + return failure("UNSUPPORTED_TEMPLATE"); + } + var budget = new Budget(); + Object copied = copy(model, 0, budget); + if (!(copied instanceof Map)) return failure("UNSUPPORTED_MODEL"); + var template = new Template("restricted-published-template", new StringReader(source), configuration); + var writer = new BoundedWriter(limits.outputBytes()); + Environment environment = template.createProcessingEnvironment(copied, writer); + environment.setTemplateProcessingTracer(budget); + environment.process(); + String html = writer.result(); + if (html.isBlank()) return failure("EMPTY_OUTPUT"); + validateHtml(html); + return new RestrictedTemplateResult(true, html, null); + } catch (Exception error) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (cause instanceof RenderLimit) return failure("RENDER_LIMIT"); + if (cause instanceof UnsupportedModel) return failure("UNSUPPORTED_MODEL"); + if (cause instanceof InvalidHtml) return failure("INVALID_HTML"); + } + return failure("RENDER_FAILED"); + } + } + + private Object copy(Object value, int depth, Budget budget) { + if (++budget.nodes > limits.modelNodes() || depth > limits.depth()) throw new UnsupportedModel(); + budget.deadline(); + if (value == null || value instanceof Boolean) return value; + if (value instanceof String text) { + if (text.length() > limits.stringChars() || !unicode(text)) throw new UnsupportedModel(); + return text; + } + if (value.getClass() == BigDecimal.class) { + var number = ((BigDecimal) value).stripTrailingZeros(); + long integer = Math.max(1L, (long) number.precision() - number.scale()); + long fraction = Math.max(0L, number.scale()); + if (integer + (fraction == 0 ? 0 : fraction + 1) + (number.signum() < 0 ? 1 : 0) > 128) throw new UnsupportedModel(); + return number; + } + if (value.getClass() == BigInteger.class) { + if (((BigInteger) value).abs().bitLength() > 426 || value.toString().length() > 128) throw new UnsupportedModel(); + return value; + } + if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) return value; + if (value instanceof Map map) { + var result = new LinkedHashMap(); + for (var entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key) || key.length() > limits.stringChars() || !unicode(key)) throw new UnsupportedModel(); + result.put(key, copy(entry.getValue(), depth + 1, budget)); + } + return result; + } + if (value instanceof List list) { + var result = new ArrayList<>(); + for (var item : list) result.add(copy(item, depth + 1, budget)); + return result; + } + throw new UnsupportedModel(); + } + + private void validateHtml(String html) throws Exception { + var factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setAttribute("http://www.oracle.com/xml/jaxp/properties/maxElementDepth", limits.depth() + 1); + var parser = factory.newDocumentBuilder(); + parser.setErrorHandler(new DefaultHandler() { + @Override public void error(SAXParseException error) throws SAXParseException { throw error; } + @Override public void fatalError(SAXParseException error) throws SAXParseException { throw error; } + }); + var root = parser.parse(new InputSource(new StringReader("" + html + ""))).getDocumentElement(); + record Visit(Node node, int depth) { } + var pending = new ArrayDeque(); + pending.add(new Visit(root, 0)); + int nodes = 0; + while (!pending.isEmpty()) { + var visit = pending.removeFirst(); + if (++nodes > 20_000 || visit.depth() > limits.depth()) throw new RenderLimit(); + Node node = visit.node(); + if (node instanceof Element element && node != root) { + if (!TAGS.contains(element.getTagName())) throw new InvalidHtml(); + for (int i = 0; i < element.getAttributes().getLength(); i++) { + Node attribute = element.getAttributes().item(i); + String name = attribute.getNodeName(), value = attribute.getNodeValue(); + if ((name.equals("class") || name.equals("id")) && value.matches("[A-Za-z0-9_ -]{1,200}")) continue; + if ((name.equals("colspan") || name.equals("rowspan") || name.equals("span")) && value.matches("[1-9][0-9]?")) continue; + if (name.equals("scope") && Set.of("row", "col", "rowgroup", "colgroup").contains(value)) continue; + throw new InvalidHtml(); + } + } else if (node != root && node.getNodeType() != Node.TEXT_NODE) throw new InvalidHtml(); + for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) pending.addLast(new Visit(child, visit.depth() + 1)); + } + if (root.getTextContent().codePoints().noneMatch(c -> !Character.isWhitespace(c) + && !Character.isSpaceChar(c) && Character.getType(c) != Character.FORMAT + && Character.getType(c) != Character.NON_SPACING_MARK + && Character.getType(c) != Character.COMBINING_SPACING_MARK + && Character.getType(c) != Character.ENCLOSING_MARK)) throw new InvalidHtml(); + } + + private static boolean unicode(String text) { + for (int i = 0; i < text.length(); i++) { + char value = text.charAt(i); + if (Character.isHighSurrogate(value)) { + if (++i >= text.length() || !Character.isLowSurrogate(text.charAt(i))) return false; + } else if (Character.isLowSurrogate(value)) return false; + } + return true; + } + private static RestrictedTemplateResult failure(String code) { return new RestrictedTemplateResult(false, null, code); } + private static final class RenderLimit extends RuntimeException { } + private static final class UnsupportedModel extends RuntimeException { } + private static final class InvalidHtml extends RuntimeException { } + + private final class Budget implements TemplateProcessingTracer { + private final long end = System.nanoTime() + limits.renderMillis() * 1_000_000; + private int nodes, steps, depth; + void deadline() { if (System.nanoTime() > end) throw new RenderLimit(); } + public void enterElement(Environment environment, TracedElement element) { + if (++steps > limits.elementSteps() || ++depth > limits.depth()) throw new RenderLimit(); + deadline(); + } + public void exitElement(Environment environment, TracedElement element) { depth--; } + } + + private static final class BoundedWriter extends Writer { + private final int limit; + private final StringBuilder text = new StringBuilder(); + private int bytes; + private boolean high; + BoundedWriter(int limit) { this.limit = limit; } + @Override public void write(char[] value, int offset, int length) { + for (int i = offset; i < offset + length; i++) { + char character = value[i]; + if (high) { + if (!Character.isLowSurrogate(character)) throw new RenderLimit(); + bytes += 4; high = false; + } else if (Character.isHighSurrogate(character)) high = true; + else if (Character.isLowSurrogate(character)) throw new RenderLimit(); + else bytes += character <= 0x7f ? 1 : character <= 0x7ff ? 2 : 3; + if (bytes > limit) throw new RenderLimit(); + text.append(character); + } + } + String result() { if (high) throw new RenderLimit(); return text.toString(); } + @Override public void flush() { } + @Override public void close() { } + } +} diff --git a/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedTemplateLimits.java b/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedTemplateLimits.java new file mode 100644 index 000000000..b454b7106 --- /dev/null +++ b/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedTemplateLimits.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.utils.template; + +/** Parsing/output/work limits; cooperative time checks are not hard CPU interruption. */ +public record RestrictedTemplateLimits(int templateBytes, int outputBytes, int modelNodes, int depth, + int stringChars, int elementSteps, long renderMillis) { + public RestrictedTemplateLimits { + if (templateBytes < 1 || outputBytes < 1 || modelNodes < 1 || depth < 1 || depth > 64 + || stringChars < 1 || elementSteps < 1 || renderMillis < 1 || renderMillis > 60_000) { + throw new IllegalArgumentException("Restricted template limits must be positive and bounded"); + } + } + public static RestrictedTemplateLimits defaults() { + return new RestrictedTemplateLimits(262_144, 1_048_576, 5_000, 32, 65_536, 10_000, 5_000); + } +} diff --git a/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedTemplateResult.java b/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedTemplateResult.java new file mode 100644 index 000000000..3999d9f33 --- /dev/null +++ b/modules/utils/src/main/java/org/fireflyframework/utils/template/RestrictedTemplateResult.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.utils.template; + +/** Failed results never carry partial content or template/model fragments. */ +public record RestrictedTemplateResult(boolean success, String html, String code) { + public RestrictedTemplateResult { + if (success ? html == null || code != null : html != null || code == null) { + throw new IllegalArgumentException("Restricted render result is inconsistent"); + } + } +} diff --git a/modules/utils/src/test/java/org/fireflyframework/utils/template/RestrictedHtmlTemplateRendererTest.java b/modules/utils/src/test/java/org/fireflyframework/utils/template/RestrictedHtmlTemplateRendererTest.java new file mode 100644 index 000000000..2a4eb3684 --- /dev/null +++ b/modules/utils/src/test/java/org/fireflyframework/utils/template/RestrictedHtmlTemplateRendererTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.utils.template; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; + +class RestrictedHtmlTemplateRendererTest { + private final RestrictedHtmlTemplateRenderer renderer = new RestrictedHtmlTemplateRenderer(); + + @Test + void actualFinancialRowsAndExactNumbersRenderAsEscapedXhtml() { + var model = Map.of("issuer", "Synthetic & Bank", "rows", List.of( + Map.of("label", "Opening ", "amount", new BigDecimal("1234.123456789012345678")), + Map.of("label", "Large integer", "amount", new BigInteger("9007199254740993")))); + var result = renderer.render("

    ${issuer}

    <#list rows as row>
    ${row.label}${row.amount}
    ", model); + assertThat(result.success()).isTrue(); + assertThat(result.html()).contains("Synthetic & Bank", "Opening <balance>", "1234.123456789012345678", "9007199254740993"); + } + + @Test + void arbitraryJavaAndTemplateModelValuesAreRejected() { + for (Object value : List.of(new Object(), java.time.Instant.now(), new freemarker.template.SimpleScalar("unsafe direct model"), 0.1d)) { + var result = renderer.render("

    ${value}

    ", Map.of("value", value)); + assertThat(result.success()).isFalse(); + assertThat(result.code()).isEqualTo("UNSUPPORTED_MODEL"); + assertThat(result.html()).isNull(); + } + } + + @Test + void classesApisAndExternalTemplateLookupsAreUnavailable() { + for (String template : List.of( + "

    ${\"java.lang.System\"?new()}

    ", + "

    ${facts?api}

    ", + "<#include '/etc/passwd'>", "<#include 'https://example.invalid/template'>", "<#import 'other.ftl' as other>")) { + var result = renderer.render(template, Map.of("facts", Map.of("name", "Synthetic"))); + assertThat(result.success()).isFalse(); + assertThat(result.html()).isNull(); + } + } + + @Test + void forcedEscapingCannotBeSuppressed() { + for (String template : List.of("<#noautoesc>

    ${value}

    ", "

    ${value?no_esc}

    ", "<#ftl output_format='plainText'>

    ${value}

    ")) { + assertThat(renderer.render(template, Map.of("value", "")).success()).isFalse(); + } + } + + @Test + void activeHtmlStylesLinksAndXmlEntitiesAreRejectedWithoutReturningPartialOutput() { + for (String template : List.of("", "

    Text

    ", + "

    Text

    ", "

    Text

    ", + "Link", "", + "]>

    &data;

    ", "

    &unknown;

    ")) { + var result = renderer.render(template, Map.of()); + assertThat(result.success()).isFalse(); + assertThat(result.html()).isNull(); + } + } + + @Test + void outputAndTemplateElementBudgetsRejectAmplification() { + var limited = new RestrictedHtmlTemplateRenderer(new RestrictedTemplateLimits(4096, 256, 100, 8, 1024, 100, 1000)); + assertThat(limited.render("

    ${value}

    ", Map.of("value", "x".repeat(512))).code()).isEqualTo("RENDER_LIMIT"); + assertThat(limited.render("<#list 1..1000000 as i>

    x

    ", Map.of()).code()).isEqualTo("RENDER_LIMIT"); + } + + @Test + void blankMalformedAndVisuallyEmptyDocumentsNeverSucceed() { + for (String template : List.of("", " ", "

    ", "

    Bad

    ", "

    ${missing}

    ")) { + assertThat(renderer.render(template, Map.of()).success()).isFalse(); + } + } + + @Test + void modelDepthCyclesAndNumericExpansionAreBounded() { + var cyclic = new java.util.HashMap(); cyclic.put("self", cyclic); + assertThat(renderer.render("

    ${self}

    ", cyclic).success()).isFalse(); + assertThat(renderer.render("

    ${amount}

    ", Map.of("amount", new BigDecimal("1e-100000"))).success()).isFalse(); + } + + @Test + void separateInstancesDoNotInheritGlobalUtilityHooksOrLoaders() throws Exception { + TemplateRenderUtil.setTemplatePostProcessor((html, model) -> "global hook leaked"); + try { + var result = renderer.render("

    Actual output

    ", Map.of()); + assertThat(result.success()).isTrue(); + assertThat(result.html()).isEqualTo("

    Actual output

    "); + } finally { + TemplateRenderUtil.setTemplatePostProcessor(null); + } + } + @Test + void harmlessMarkupCannotBeInjectedBySuppressingEscaping() { + var result = renderer.render("

    ${value?no_esc}

    ", Map.of("value", "Injected")); + assertThat(result.success()).isFalse(); + } + + @Test + void utf8OutputLimitCountsSupplementaryCharactersAndInstancesRecoverAfterFailure() { + var limited = new RestrictedHtmlTemplateRenderer(new RestrictedTemplateLimits(4096, 20, 100, 8, 1024, 100, 1000)); + assertThat(limited.render("

    ${value}

    ", Map.of("value", "😀".repeat(4))).code()).isEqualTo("RENDER_LIMIT"); + assertThat(limited.render("

    ${value}

    ", Map.of("value", "😀".repeat(3))).success()).isTrue(); + assertThat(limited.render("

    Recovered

    ", Map.of()).success()).isTrue(); + assertThat(renderer.render("

    ${value}

    ", Map.of("value", "\ud800")).success()).isFalse(); + } + @Test + void nonbreakingSpaceAndInvisibleFormatOnlyOutputIsEmpty() { + for (String template : List.of("

     

    ", "

    ​

    ")) { + assertThat(renderer.render(template, Map.of()).success()).isFalse(); + } + } + @Test + void classResolverBlocksARealConstructorCapabilityWithSafePositiveControl() throws Exception { + String source = "

    ${\"freemarker.template.utility.ObjectConstructor\"?new()(\"java.lang.String\", \"constructed\")}

    "; + var unrestricted = new freemarker.template.Configuration(freemarker.template.Configuration.VERSION_2_3_35); + var template = new freemarker.template.Template("private-positive-control", new java.io.StringReader(source), unrestricted); + var out = new java.io.StringWriter(); + template.process(Map.of(), out); + assertThat(out.toString()).isEqualTo("

    constructed

    "); + assertThat(renderer.render(source, Map.of()).success()).isFalse(); + } +} diff --git a/modules/utils/tutorials/README.md b/modules/utils/tutorials/README.md index b9394dc93..864540a68 100644 --- a/modules/utils/tutorials/README.md +++ b/modules/utils/tutorials/README.md @@ -11,7 +11,7 @@ Before starting these tutorials, make sure you have: org.fireflyframework fireflyframework-utils - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/validators/README.md b/modules/validators/README.md index bfdcadf8b..13ddc7d9a 100644 --- a/modules/validators/README.md +++ b/modules/validators/README.md @@ -20,7 +20,7 @@ The tutorial validates name and email with Jakarta constraints. Later onboarding org.fireflyframework fireflyframework-validators - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/validators/docs/offline-json-schema-validation.md b/modules/validators/docs/offline-json-schema-validation.md new file mode 100644 index 000000000..95bdc5119 --- /dev/null +++ b/modules/validators/docs/offline-json-schema-validation.md @@ -0,0 +1,48 @@ +# Offline JSON Schema validation + +`OfflineJsonSchemaValidator` validates JSON Schema documents and JSON instances through a vendor-neutral string API. It belongs to `fireflyframework-validators`; applications retain their business schema, authorization and lifecycle rules. The implementation uses NetworkNT 3.0.8 and Jackson 3 internally. Public results expose only Java records and strings, so a Jackson 2 consumer can supply exact JSON without translating vendor nodes. + +```java +var validator = new OfflineJsonSchemaValidator(); +var schemaResult = validator.validateSchema(schemaJson); +if (schemaResult.valid()) { + var instanceResult = validator.validateInstance(schemaJson, instanceJson); +} +``` + +`SchemaValidationResult` contains `valid` and an immutable list of `SchemaValidationIssue(code, instanceLocation, message)`. Codes distinguish `UNSUPPORTED_SCHEMA` (outside the closed profile), `INVALID_SCHEMA` (malformed JSON, invalid schema constraints or unresolved references), `INVALID_INSTANCE` (malformed or excessive input), and `INSTANCE_CONSTRAINT` (well-formed input that violates the schema). Application-facing messages omit schema fragments and instance values. Instance locations can contain caller-controlled object property names: do not log them without an application's data-handling policy. + +## Supported profile + +The schema must be an object with explicit `$schema: "https://json-schema.org/draft/2020-12/schema"`. Boolean schemas are unsupported at the document root. Nested boolean schemas remain valid according to Draft 2020-12. The official bundled metaschema validates keyword structure; the engine also compiles validators to resolve local references. Unknown application keywords remain annotations, as permitted by JSON Schema. + +Only document-local JSON Pointer `$ref` fragments are supported, including correctly escaped and URI-encoded pointers. The reference/containment graph must be acyclic. Unresolved pointers fail validation. References must target recognized Draft 2020-12 schema positions: the root, `$defs`/`properties`/`patternProperties`/`dependentSchemas` entries, schema-valued keywords or schema-array entries. Executing literal `const`/`enum` or unknown annotation objects as referenced schemas is unsupported. URI decoding normalizes only executable schema references on a private compilation copy; literal constraint data is preserved. + +External URI references, `$id`, `$anchor`, `$dynamicAnchor`, `$dynamicRef`, custom `$vocabulary`, and another declared dialect are unsupported. The profile checks every JSON object conservatively, including annotation and `const` data objects: a `$ref` or `$id` there is subject to the same restriction. + +The loader permits only a fixed list of bundled Draft 2020-12 metaschema resources. Remote retrieval is disabled. Caller-supplied file, classpath, HTTP and HTTPS schema references are rejected before loading, even when the URI points to an official metaschema. No external schema registry is consulted. + +Format assertions are enabled, including actual calendar-date validation. Numbers are parsed as arbitrary-precision integers/decimals; the qualification cases include integers above 2^53 and exact 18-place `multipleOf` constraints. Regex evaluation uses the upstream default Java regex implementation; this is not a claim of complete ECMA-262 regex compatibility. + +## Limits + +`new OfflineJsonSchemaValidator(Limits.defaults())` applies: + +| Limit | Default | +| --- | ---: | +| Schema UTF-8 bytes | 65,536 | +| Instance UTF-8 bytes | 1,048,576 | +| JSON nesting depth | 32 | +| String length | 65,536 | +| Number token length | 128 | +| Nodes in each document | 5,000 | +| Schema references | 128 | +| Returned constraint issues | 20 | + +A custom immutable `Limits(schemaBytes, instanceBytes, depth, stringLength, numberLength, nodes, references)` must stay within constructor bounds. Duplicate fields, trailing JSON documents, excess UTF-8 bytes, depth, strings, number tokens, nodes and references are rejected. These limits and cycle rejection bound parsing and reference structure. They do not impose a hard CPU deadline or prove cheap evaluation for every schema: combinations and regex patterns can be expensive. Applications must govern who may author schemas and control execution resources for their workload. + +## Qualification + +Run the affected module with Java 25: `mvn -pl modules/validators -am verify`. The schema tests exercise bundled metaschema errors, instance constraints, offline rejection with a local listening socket, recursion, escaped/encoded pointers, precision, dates, duplicate/trailing inputs and configured limits. These checks qualify this mechanism; they do not establish any application's publishing authority, domain-specific schema fitness or document-generation acceptance. + +The upstream API and bundled metaschema behavior are described in the [NetworkNT 3.0.8 documentation](https://github.com/networknt/json-schema-validator/tree/3.0.8). The wrapper deliberately narrows that library's supported profile. diff --git a/modules/validators/pom.xml b/modules/validators/pom.xml index ef439ef80..c39c8e40b 100644 --- a/modules/validators/pom.xml +++ b/modules/validators/pom.xml @@ -7,18 +7,23 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-validators - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Validators Bean validation constraints and validators for Spring Boot applications + + com.networknt + json-schema-validator + 3.0.8 + org.springframework.boot spring-boot-starter-validation diff --git a/modules/validators/src/main/java/org/fireflyframework/validators/schema/OfflineJsonSchemaValidator.java b/modules/validators/src/main/java/org/fireflyframework/validators/schema/OfflineJsonSchemaValidator.java new file mode 100644 index 000000000..ece4b0e68 --- /dev/null +++ b/modules/validators/src/main/java/org/fireflyframework/validators/schema/OfflineJsonSchemaValidator.java @@ -0,0 +1,241 @@ +package org.fireflyframework.validators.schema; + +import com.networknt.schema.dialect.Dialects; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.resource.SchemaLoader; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Offline Draft 2020-12 validation with a bounded, acyclic, document-local reference profile. + * Public contracts deliberately contain neither vendor types nor Jackson nodes. + */ +public final class OfflineJsonSchemaValidator { + public static final String DIALECT = "https://json-schema.org/draft/2020-12/schema"; + private static final Set META_RESOURCES = Set.of(DIALECT, + "https://json-schema.org/draft/2020-12/meta/core", "https://json-schema.org/draft/2020-12/meta/applicator", + "https://json-schema.org/draft/2020-12/meta/unevaluated", "https://json-schema.org/draft/2020-12/meta/validation", + "https://json-schema.org/draft/2020-12/meta/meta-data", "https://json-schema.org/draft/2020-12/meta/format-annotation", + "https://json-schema.org/draft/2020-12/meta/content", "https://json-schema.org/draft/2020-12/meta/format-assertion"); + + /** Limits bound input parsing and reference structure, not total validation CPU time. */ + public record Limits(int schemaBytes, int instanceBytes, int depth, int stringLength, int numberLength, int nodes, int references) { + public Limits { + if (schemaBytes < 1 || schemaBytes > 1_048_576 || instanceBytes < 1 || instanceBytes > 16_777_216 + || depth < 1 || depth > 128 || stringLength < 1 || stringLength > 1_048_576 + || numberLength < 1 || numberLength > 1_024 || nodes < 1 || nodes > 100_000 + || references < 1 || references > 1_024) { + throw new IllegalArgumentException("JSON Schema limits are outside the supported range"); + } + } + public static Limits defaults() { return new Limits(65_536, 1_048_576, 32, 65_536, 128, 5_000, 128); } + } + + private final Limits limits; + private final JsonMapper mapper; + private final SchemaRegistry registry; + private final Schema metaSchema; + + public OfflineJsonSchemaValidator() { this(Limits.defaults()); } + + public OfflineJsonSchemaValidator(Limits limits) { + this.limits = java.util.Objects.requireNonNull(limits); + var factory = JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(limits.depth()) + .maxStringLength(limits.stringLength()).maxNumberLength(limits.numberLength()).build()).build(); + mapper = JsonMapper.builder(factory).enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS).build(); + registry = SchemaRegistry.withDialect(Dialects.getDraft202012(), builder -> builder + .schemaLoader(SchemaLoader.builder().fetchRemoteResources(false) + .allow(iri -> META_RESOURCES.contains(iri.toString())).build())); + metaSchema = registry.getSchema(SchemaLocation.of(DIALECT)); + } + + public SchemaValidationResult validateSchema(String schemaJson) { + try { + compile(schemaJson); + return valid(); + } catch (ProfileException error) { + return invalid(error.code, "", error.getMessage()); + } catch (RuntimeException error) { + return invalid("INVALID_SCHEMA", "", "Schema syntax, constraints or references are invalid"); + } + } + + public SchemaValidationResult validateInstance(String schemaJson, String instanceJson) { + final Schema schema; + try { + schema = compile(schemaJson); + } catch (ProfileException error) { + return invalid(error.code, "", error.getMessage()); + } catch (RuntimeException error) { + return invalid("INVALID_SCHEMA", "", "Schema syntax, constraints or references are invalid"); + } + try { + var instance = parse(instanceJson, limits.instanceBytes()); + countNodes(instance); + var errors = schema.validate(instance, context -> context.executionConfig(config -> config.formatAssertionsEnabled(true))); + if (errors.isEmpty()) return valid(); + // Vendor messages can contain instance values, so only stable application messages leave this boundary. + return new SchemaValidationResult(false, errors.stream().limit(20).map(error -> + new SchemaValidationIssue("INSTANCE_CONSTRAINT", error.getInstanceLocation().toString(), + "Instance does not satisfy the schema")).toList()); + } catch (RuntimeException error) { + return invalid("INVALID_INSTANCE", "", "Instance syntax or input limits are invalid"); + } + } + + private Schema compile(String json) { + JsonNode root = parse(json, limits.schemaBytes()); + if (!root.isObject() || !DIALECT.equals(root.path("$schema").asString())) { + throw unsupported("Only explicit Draft 2020-12 object schemas are supported"); + } + Map> graph = new LinkedHashMap<>(); + var schemaLocations = new java.util.HashSet(); + collectSchemaLocations(root, "", schemaLocations); + collect(root, "", graph, schemaLocations, new int[2]); + rejectCycles(graph); + if (!metaSchema.validate(root, context -> context.executionConfig(config -> config.formatAssertionsEnabled(true))).isEmpty()) { + throw new ProfileException("INVALID_SCHEMA", "Schema does not satisfy the Draft 2020-12 metaschema"); + } + Schema result = registry.getSchema(normalizeLocalReferences(root.deepCopy(), schemaLocations)); + result.initializeValidators(); + return result; + } + + private JsonNode normalizeLocalReferences(JsonNode root, Set schemaLocations) { + for (String location : schemaLocations) { + JsonNode node = root.at(location); + if (node.isObject() && node.has("$ref")) { + var object = (tools.jackson.databind.node.ObjectNode) node; + object.put("$ref", "#" + URI.create(node.get("$ref").asString()).getFragment()); + } + } + return root; + } + + private void collectSchemaLocations(JsonNode node, String path, Set locations) { + locations.add(path); + if (!node.isObject()) return; + for (var property : node.properties()) { + String keyword = property.getKey(); + JsonNode value = property.getValue(); + String child = path + "/" + escape(keyword); + if (Set.of("additionalProperties", "unevaluatedProperties", "propertyNames", "contains", "items", + "unevaluatedItems", "not", "if", "then", "else", "contentSchema").contains(keyword)) { + collectSchemaLocations(value, child, locations); + } else if (Set.of("$defs", "properties", "patternProperties", "dependentSchemas").contains(keyword) && value.isObject()) { + for (var entry : value.properties()) collectSchemaLocations(entry.getValue(), child + "/" + escape(entry.getKey()), locations); + } else if (Set.of("allOf", "anyOf", "oneOf", "prefixItems").contains(keyword) && value.isArray()) { + for (int index = 0; index < value.size(); index++) collectSchemaLocations(value.get(index), child + "/" + index, locations); + } + } + } + + private static String escape(String key) { return key.replace("~", "~0").replace("/", "~1"); } + + private JsonNode parse(String json, int maxBytes) { + if (json == null || json.length() > maxBytes || json.getBytes(StandardCharsets.UTF_8).length > maxBytes) { + throw new IllegalArgumentException("Input exceeds the byte limit"); + } + JsonNode result = mapper.readTree(json); + if (result == null || result.isMissingNode()) throw new IllegalArgumentException("A JSON document is required"); + return result; + } + + private void countNodes(JsonNode root) { + var pending = new ArrayDeque(); + pending.add(root); + int nodes = 0; + while (!pending.isEmpty()) { + JsonNode node = pending.removeFirst(); + if (++nodes > limits.nodes()) throw new IllegalArgumentException("Input exceeds the node limit"); + node.forEach(pending::addLast); + } + } + + private void collect(JsonNode root, String path, Map> graph, Set schemaLocations, int[] counts) { + JsonNode node = root.at(path); + if (++counts[0] > limits.nodes()) throw unsupported("Schema exceeds the node limit"); + List edges = new ArrayList<>(); + graph.put(path, edges); + if (node.isObject()) { + for (var property : node.properties()) { + String key = property.getKey(); + JsonNode value = property.getValue(); + if (Set.of("$id", "$dynamicRef", "$dynamicAnchor", "$anchor", "$vocabulary").contains(key)) { + throw unsupported("Schema resource identifiers, anchors and custom vocabularies are unsupported"); + } + if (key.equals("$schema") && !DIALECT.equals(value.asString())) throw unsupported("Schema dialect override is unsupported"); + if (key.equals("$ref")) { + if (++counts[1] > limits.references()) throw unsupported("Schema exceeds the reference limit"); + if (!value.isString() || !value.asString().startsWith("#")) throw unsupported("External schema references are unsupported"); + String pointer = URI.create(value.asString()).getFragment(); + if (pointer == null || (!pointer.isEmpty() && !pointer.startsWith("/"))) throw unsupported("Only local JSON pointer references are supported"); + if (root.at(pointer).isMissingNode()) throw new ProfileException("INVALID_SCHEMA", "Local schema reference is unresolved"); + if (!schemaLocations.contains(pointer)) throw unsupported("References to literal or annotation data are unsupported"); + edges.add(pointer); + } + String child = path + "/" + escape(key); + edges.add(child); + collect(root, child, graph, schemaLocations, counts); + } + } else if (node.isArray()) { + for (int index = 0; index < node.size(); index++) { + String child = path + "/" + index; + edges.add(child); + collect(root, child, graph, schemaLocations, counts); + } + } + } + + private void rejectCycles(Map> graph) { + record Frame(String path, Iterator children) { } + Map colors = new HashMap<>(); + var stack = new ArrayDeque(); + colors.put("", 1); + stack.push(new Frame("", graph.get("").iterator())); + while (!stack.isEmpty()) { + Frame current = stack.peek(); + if (!current.children().hasNext()) { + colors.put(current.path(), 2); + stack.pop(); + continue; + } + String next = current.children().next(); + if (colors.getOrDefault(next, 0) == 1) throw unsupported("Recursive schema references are unsupported"); + if (colors.getOrDefault(next, 0) == 0) { + colors.put(next, 1); + stack.push(new Frame(next, graph.get(next).iterator())); + } + } + } + + private static SchemaValidationResult valid() { return new SchemaValidationResult(true, List.of()); } + private static SchemaValidationResult invalid(String code, String path, String message) { + return new SchemaValidationResult(false, List.of(new SchemaValidationIssue(code, path, message))); + } + private static ProfileException unsupported(String message) { return new ProfileException("UNSUPPORTED_SCHEMA", message); } + private static final class ProfileException extends RuntimeException { + private final String code; + private ProfileException(String code, String message) { super(message); this.code = code; } + } +} diff --git a/modules/validators/src/main/java/org/fireflyframework/validators/schema/SchemaValidationIssue.java b/modules/validators/src/main/java/org/fireflyframework/validators/schema/SchemaValidationIssue.java new file mode 100644 index 000000000..f06730d93 --- /dev/null +++ b/modules/validators/src/main/java/org/fireflyframework/validators/schema/SchemaValidationIssue.java @@ -0,0 +1,5 @@ +package org.fireflyframework.validators.schema; + +/** Sanitized issue with no schema or instance values. */ +public record SchemaValidationIssue(String code, String instanceLocation, String message) { +} diff --git a/modules/validators/src/main/java/org/fireflyframework/validators/schema/SchemaValidationResult.java b/modules/validators/src/main/java/org/fireflyframework/validators/schema/SchemaValidationResult.java new file mode 100644 index 000000000..cfddea901 --- /dev/null +++ b/modules/validators/src/main/java/org/fireflyframework/validators/schema/SchemaValidationResult.java @@ -0,0 +1,11 @@ +package org.fireflyframework.validators.schema; + +import java.util.List; + +/** Vendor-neutral immutable validation outcome. */ +public record SchemaValidationResult(boolean valid, List issues) { + public SchemaValidationResult { + issues = List.copyOf(issues); + if (valid != issues.isEmpty()) throw new IllegalArgumentException("Validation outcome and issues disagree"); + } +} diff --git a/modules/validators/src/test/java/org/fireflyframework/validators/schema/OfflineJsonSchemaValidatorTest.java b/modules/validators/src/test/java/org/fireflyframework/validators/schema/OfflineJsonSchemaValidatorTest.java new file mode 100644 index 000000000..5d5182cd8 --- /dev/null +++ b/modules/validators/src/test/java/org/fireflyframework/validators/schema/OfflineJsonSchemaValidatorTest.java @@ -0,0 +1,126 @@ +package org.fireflyframework.validators.schema; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OfflineJsonSchemaValidatorTest { + private final OfflineJsonSchemaValidator validator = new OfflineJsonSchemaValidator(); + private static final String PREFIX = "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\","; + + @Test void validatesTheSchemaAgainstTheBundledMetaschema() { + assertThat(validator.validateSchema(PREFIX + "\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}").valid()).isTrue(); + assertThat(validator.validateSchema(PREFIX + "\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"invented\"}}}").valid()).isFalse(); + assertThat(validator.validateSchema(PREFIX + "\"required\":\"name\"}").valid()).isFalse(); + assertThat(validator.validateSchema(PREFIX + "\"minLength\":-1}").valid()).isFalse(); + } + + @Test void localNonrecursiveReferencesActuallyValidateInstanceData() { + String schema = PREFIX + "\"type\":\"object\",\"$defs\":{\"positive\":{\"type\":\"integer\",\"minimum\":1}}," + + "\"properties\":{\"amount\":{\"$ref\":\"#/$defs/positive\"}},\"required\":[\"amount\"]}"; + assertThat(validator.validateInstance(schema, "{\"amount\":2}").valid()).isTrue(); + assertThat(validator.validateInstance(schema, "{\"amount\":0}").valid()).isFalse(); + assertThat(validator.validateInstance(schema, "{\"amount\":\"invented\"}").valid()).isFalse(); + } + + @Test void unavailableReferencesAndUnsupportedDialectsFailClosed() { + for (String schema : java.util.List.of( + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\"}", + PREFIX + "\"$ref\":\"#/$defs/missing\"}", + PREFIX + "\"$id\":\"https://example.invalid/other\"}", + PREFIX + "\"$dynamicRef\":\"#name\"}", + PREFIX + "\"$ref\":\"#\"}")) { + assertThat(validator.validateSchema(schema).valid()).as(schema).isFalse(); + } + } + + @Test void deniesEveryExternalRetrievalWithoutContactingTheSuppliedServer() throws Exception { + try (var socket = new java.net.ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())) { + socket.setSoTimeout(100); + for (String uri : java.util.List.of("http://127.0.0.1:" + socket.getLocalPort() + "/schema", "file:///etc/passwd", + "classpath:application.yml", "https://json-schema.org/draft/2020-12/schema")) { + assertThat(validator.validateSchema(PREFIX + "\"$ref\":\"" + uri + "\"}").valid()).isFalse(); + } + org.assertj.core.api.Assertions.assertThatThrownBy(socket::accept).isInstanceOf(java.net.SocketTimeoutException.class); + } + } + + @Test void parserRejectsDuplicatesTrailingTokensAndBoundedInputs() { + assertThat(validator.validateSchema(PREFIX + "\"type\":\"object\",\"type\":\"string\"}").valid()).isFalse(); + assertThat(validator.validateSchema(PREFIX + "\"type\":\"object\"} {}").valid()).isFalse(); + assertThat(validator.validateSchema(PREFIX + "\"description\":\"" + "x".repeat(70_000) + "\"}").valid()).isFalse(); + assertThat(validator.validateInstance(PREFIX + "\"type\":\"object\"}", "[".repeat(40) + "0" + "]".repeat(40)).valid()).isFalse(); + assertThat(validator.validateInstance(PREFIX + "\"type\":\"number\"}", "1".repeat(200)).valid()).isFalse(); + } + + @Test void integerAndDecimalConstraintsRetainExactPrecision() { + assertThat(validator.validateInstance(PREFIX + "\"minimum\":9007199254740993}", "9007199254740992").valid()).isFalse(); + assertThat(validator.validateInstance(PREFIX + "\"minimum\":9007199254740993}", "9007199254740993").valid()).isTrue(); + assertThat(validator.validateInstance(PREFIX + "\"multipleOf\":0.000000000000000003}", "0.123456789012345678").valid()).isTrue(); + assertThat(validator.validateInstance(PREFIX + "\"multipleOf\":0.000000000000000003}", "0.123456789012345679").valid()).isFalse(); + } + + @Test void dateFormatAssertionsAreEnabledAndErrorsDoNotDiscloseValues() { + var result = validator.validateInstance(PREFIX + "\"type\":\"string\",\"format\":\"date\"}", "\"2026-02-30\""); + assertThat(result.valid()).isFalse(); + assertThat(result.issues()).allSatisfy(issue -> { + assertThat(issue.code()).isEqualTo("INSTANCE_CONSTRAINT"); + assertThat(issue.message()).doesNotContain("2026-02-30"); + }); + assertThat(validator.validateInstance(PREFIX + "\"type\":\"string\",\"format\":\"date\"}", "\"2026-02-28\"").valid()).isTrue(); + } + + @Test void localPointersPreserveEscapedAndEncodedPropertyNames() { + String schema = PREFIX + "\"$defs\":{\"a/b~c\":{\"type\":\"integer\"}},\"$ref\":\"#%2F$defs%2Fa~1b~0c\"}"; + assertThat(validator.validateInstance(schema, "123").valid()).isTrue(); + assertThat(validator.validateInstance(schema, "\"123\"").valid()).isFalse(); + assertThat(validator.validateSchema(PREFIX + "\"$ref\":\"#%2F..%2Ffile:%2F%2Fetc%2Fpasswd\"}").valid()).isFalse(); + } + + @Test void configurableNodeReferenceAndUtf8ByteLimitsActuallyRejectOverages() { + var small = new OfflineJsonSchemaValidator(new OfflineJsonSchemaValidator.Limits(150, 100, 8, 100, 20, 20, 1)); + assertThat(small.validateSchema(PREFIX + "\"description\":\"" + "é".repeat(45) + "\"}").valid()).isFalse(); + assertThat(small.validateInstance(PREFIX + "\"type\":\"array\"}", "[" + "1,".repeat(20) + "1]").valid()).isFalse(); + var refs = new OfflineJsonSchemaValidator(new OfflineJsonSchemaValidator.Limits(1024, 100, 8, 100, 20, 100, 1)); + assertThat(refs.validateSchema(PREFIX + "\"$defs\":{\"x\":{}},\"allOf\":[{\"$ref\":\"#/$defs/x\"},{\"$ref\":\"#/$defs/x\"}]}").valid()).isFalse(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> new OfflineJsonSchemaValidator.Limits(0,1,1,1,1,1,1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test void closedProfileDistinguishesUnsupportedSchemasFromInvalidSchemas() { + assertThat(validator.validateSchema("true").issues()).extracting(SchemaValidationIssue::code).containsOnly("UNSUPPORTED_SCHEMA"); + assertThat(validator.validateSchema(PREFIX + "\"type\":13}").issues()).extracting(SchemaValidationIssue::code).containsOnly("INVALID_SCHEMA"); + assertThat(validator.validateSchema(PREFIX + "\"unknownApplicationAnnotation\":\"permitted annotation\"}").valid()).isTrue(); + assertThat(validator.validateSchema(PREFIX + "\"const\":{\"$ref\":\"https://example.invalid/data\"}}").issues()) + .extracting(SchemaValidationIssue::code).containsOnly("UNSUPPORTED_SCHEMA"); + } + + @Test void normalizationNeverRewritesConstOrEnumConstraintData() { + String schema = PREFIX + "\"$defs\":{\"x\":{}},\"const\":{\"$ref\":\"#%2F$defs%2Fx\"}}"; + assertThat(validator.validateInstance(schema, "{\"$ref\":\"#%2F$defs%2Fx\"}").valid()).isTrue(); + assertThat(validator.validateInstance(schema, "{\"$ref\":\"#/$defs/x\"}").valid()).isFalse(); + String enumeration = PREFIX + "\"$defs\":{\"x\":{}},\"enum\":[{\"$ref\":\"#%2F$defs%2Fx\"}]}"; + assertThat(validator.validateInstance(enumeration, "{\"$ref\":\"#%2F$defs%2Fx\"}").valid()).isTrue(); + } + + @Test void blankInputIsNeverAnInstanceEvenForAnUnconstrainedSchema() { + for (String input : java.util.List.of("", " ", "\n\t")) { + var result = validator.validateInstance(PREFIX + "\"description\":\"Any valid JSON value\"}", input); + assertThat(result.valid()).isFalse(); + assertThat(result.issues()).extracting(SchemaValidationIssue::code).containsOnly("INVALID_INSTANCE"); + } + } + + @Test void referencesCannotExecuteLiteralOrUnknownAnnotationObjectsAsSchemas() { + for (String schema : java.util.List.of(PREFIX + "\"const\":{\"type\":\"string\"},\"$ref\":\"#/const\"}", + PREFIX + "\"applicationAnnotation\":{\"type\":\"string\"},\"$ref\":\"#/applicationAnnotation\"}")) { + assertThat(validator.validateSchema(schema).issues()).extracting(SchemaValidationIssue::code).containsOnly("UNSUPPORTED_SCHEMA"); + } + assertThat(validator.validateSchema(" ").issues()).extracting(SchemaValidationIssue::code).containsOnly("INVALID_SCHEMA"); + } + + @Test void recursiveReferenceGraphsAreRejectedBeforeExecution() { + String schema = PREFIX + "\"$defs\":{\"a\":{\"$ref\":\"#/$defs/b\"},\"b\":{\"$ref\":\"#/$defs/a\"}},\"$ref\":\"#/$defs/a\"}"; + assertThat(validator.validateSchema(schema).valid()).isFalse(); + } +} diff --git a/modules/web/README.md b/modules/web/README.md index b097820fd..9a873ed48 100644 --- a/modules/web/README.md +++ b/modules/web/README.md @@ -20,7 +20,7 @@ The onboarding HTTP boundary needs consistent error responses and carefully chos org.fireflyframework fireflyframework-web - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/web/pom.xml b/modules/web/pom.xml index e3010374e..2c4458afc 100644 --- a/modules/web/pom.xml +++ b/modules/web/pom.xml @@ -7,12 +7,12 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml fireflyframework-web - 26.10.21-SNAPSHOT + 26.10.22 jar Firefly Framework - Web @@ -82,6 +82,18 @@ true + + org.fireflyframework + fireflyframework-security-api + ${fireflyframework.version} + + + + org.fireflyframework + fireflyframework-security-spi + ${fireflyframework.version} + + org.springframework.security @@ -139,6 +151,11 @@ org.springframework.boot spring-boot-jackson2 + + org.openapitools + jackson-databind-nullable + true + diff --git a/modules/web/src/main/java/org/fireflyframework/web/error/handler/GlobalExceptionHandler.java b/modules/web/src/main/java/org/fireflyframework/web/error/handler/GlobalExceptionHandler.java index c6f545924..8de5633e7 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/error/handler/GlobalExceptionHandler.java +++ b/modules/web/src/main/java/org/fireflyframework/web/error/handler/GlobalExceptionHandler.java @@ -184,7 +184,7 @@ private Mono handleBusinessException(ServerWebExchange exchange, BusinessE .message(maskSensitiveData(ex.getMessage())) .path(exchange.getRequest().getPath().value()) .code(ex.getCode()) - .metadata(ex.getMetadata()); + .metadata(responseMetadata(ex.getMetadata())); // Add distributed tracing information enrichWithTracingInfo(builder, exchange); @@ -235,7 +235,7 @@ private Mono handleCustomValidationException(ServerWebExchange exchange, V .field(error.getField()) .code(error.getCode()) .message(error.getMessage()) - .metadata(error.getMetadata()) + .metadata(responseMetadata(error.getMetadata())) .build()) .collect(Collectors.toList()); @@ -249,7 +249,7 @@ private Mono handleCustomValidationException(ServerWebExchange exchange, V .traceId(UUID.randomUUID().toString()) .code(ex.getCode()) .errors(validationErrors) - .metadata(ex.getMetadata()) + .metadata(responseMetadata(ex.getMetadata())) .suggestion("Please check the validation errors and correct your request.") .documentation(errorProperties.getDocumentationBaseUrl() != null ? errorProperties.getDocumentationBaseUrl() + "/validation" : null) .build(); @@ -268,7 +268,7 @@ private Mono handleCustomValidationException(ServerWebExchange exchange, V @ExceptionHandler(WebExchangeBindException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) private Mono handleValidationException(ServerWebExchange exchange, WebExchangeBindException ex) { - log.error("Validation exception occurred: ", ex); + log.warn("Request validation failed with {} errors", ex.getErrorCount()); // Convert Spring validation errors to our enhanced format List validationErrors = ex.getBindingResult() @@ -284,10 +284,7 @@ private Mono handleValidationException(ServerWebExchange exchange, WebExch // Add additional metadata for specific validation errors if (error instanceof FieldError fieldError) { - if (fieldError.getRejectedValue() != null) { - metadata.put("rejectedValue", fieldError.getRejectedValue().toString()); - metadata.put("bindingFailure", fieldError.isBindingFailure()); - } + metadata.put("bindingFailure", fieldError.isBindingFailure()); } return ErrorResponse.ValidationError.builder() @@ -344,7 +341,7 @@ private Mono handleResponseStatusException(ServerWebExchange exchange, Res Map metadata = convertedException.getMetadata(); // Add metadata to the response - builder.metadata(metadata); + builder.metadata(responseMetadata(metadata)); // Add suggestion from metadata if available if (metadata != null && metadata.containsKey("suggestion")) { @@ -638,6 +635,24 @@ private void enrichWithDocumentation(Object builder, BusinessException ex) { } } + private Map responseMetadata(Map metadata) { + if (metadata == null) { + return null; + } + Map response = new HashMap<>(metadata); + boolean production = isProductionEnvironment(); + if (production || !errorProperties.isIncludeDebugInfo()) { + for (String key : List.of("sourceClass", "sourceMethod", "sourceLine", "sourceFile", "exceptionType")) { + response.remove(key); + } + } + if (production || !errorProperties.isIncludeExceptionCause()) { + response.remove("causeType"); + response.remove("causeMessage"); + } + return response.isEmpty() ? null : response; + } + /** * Enriches error response with environment-specific debug information. * diff --git a/modules/web/src/main/java/org/fireflyframework/web/idempotency/config/IdempotencyAutoConfiguration.java b/modules/web/src/main/java/org/fireflyframework/web/idempotency/config/IdempotencyAutoConfiguration.java index 14958ecc3..70eb688d5 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/idempotency/config/IdempotencyAutoConfiguration.java +++ b/modules/web/src/main/java/org/fireflyframework/web/idempotency/config/IdempotencyAutoConfiguration.java @@ -23,6 +23,8 @@ import org.fireflyframework.web.idempotency.cache.FireflyCacheIdempotencyAdapter; import org.fireflyframework.web.idempotency.cache.IdempotencyCache; import org.fireflyframework.web.idempotency.filter.IdempotencyWebFilter; +import org.fireflyframework.web.idempotency.scope.AuthenticatedIdempotencyScopeResolver; +import org.fireflyframework.web.idempotency.scope.IdempotencyScopeResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -141,25 +143,28 @@ public IdempotencyCache idempotencyCache( return new FireflyCacheIdempotencyAdapter(cacheManager, properties); } - /** - * Creates the IdempotencyWebFilter bean. - * - *

    This filter intercepts HTTP requests and provides idempotency support - * for POST, PUT, and PATCH requests that include the X-Idempotency-Key header.

    - * - *

    This bean is only created when an IdempotencyCache bean exists.

    - * - * @param properties the idempotency configuration properties - * @param cache the idempotency cache implementation - * @return the idempotency web filter - */ + /** Supplies a trusted identity resolver; applications may replace it for their authentication stack. */ + @Bean + @ConditionalOnMissingBean(IdempotencyScopeResolver.class) + public IdempotencyScopeResolver idempotencyScopeResolver( + org.springframework.beans.factory.ObjectProvider tenantResolvers) { + return new AuthenticatedIdempotencyScopeResolver(tenantResolvers.getIfAvailable()); + } + + /** Creates a scoped mutation-response filter after authentication and before handler invocation. */ @Bean @ConditionalOnBean(IdempotencyCache.class) @ConditionalOnMissingBean(IdempotencyWebFilter.class) public IdempotencyWebFilter idempotencyWebFilter( IdempotencyProperties properties, - IdempotencyCache cache) { - return new IdempotencyWebFilter(properties, cache); + IdempotencyCache cache, + IdempotencyScopeResolver scopeResolver, + @org.springframework.beans.factory.annotation.Value("${spring.application.name:}") String applicationName, + org.springframework.beans.factory.ObjectProvider mappings) { + if (properties.getNamespace() == null || properties.getNamespace().isBlank()) { + properties.setNamespace(applicationName); + } + return new IdempotencyWebFilter(properties, cache, scopeResolver, () -> mappings.orderedStream().toList()); } } diff --git a/modules/web/src/main/java/org/fireflyframework/web/idempotency/config/IdempotencyProperties.java b/modules/web/src/main/java/org/fireflyframework/web/idempotency/config/IdempotencyProperties.java index 7111eaa2a..91791c875 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/idempotency/config/IdempotencyProperties.java +++ b/modules/web/src/main/java/org/fireflyframework/web/idempotency/config/IdempotencyProperties.java @@ -68,6 +68,45 @@ public class IdempotencyProperties { */ private Cache cache = new Cache(); + /** Stable service/environment namespace; auto-configuration falls back to spring.application.name. */ + private String namespace; + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + /** Maximum buffered keyed request size; larger requests fail before handler execution. */ + private int maxRequestBodyBytes = 1024 * 1024; + + /** Maximum buffered response size. Streaming endpoints should disable this filter. */ + private int maxResponseBodyBytes = 4 * 1024 * 1024; + + public int getMaxRequestBodyBytes() { + return maxRequestBodyBytes; + } + + public void setMaxRequestBodyBytes(int value) { + if (value <= 0) { + throw new IllegalArgumentException("maxRequestBodyBytes must be positive"); + } + this.maxRequestBodyBytes = value; + } + + public int getMaxResponseBodyBytes() { + return maxResponseBodyBytes; + } + + public void setMaxResponseBodyBytes(int value) { + if (value <= 0) { + throw new IllegalArgumentException("maxResponseBodyBytes must be positive"); + } + this.maxResponseBodyBytes = value; + } + public boolean isEnabled() { return enabled; } diff --git a/modules/web/src/main/java/org/fireflyframework/web/idempotency/filter/IdempotencyWebFilter.java b/modules/web/src/main/java/org/fireflyframework/web/idempotency/filter/IdempotencyWebFilter.java index 8dd23f606..0ce6e52f9 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/idempotency/filter/IdempotencyWebFilter.java +++ b/modules/web/src/main/java/org/fireflyframework/web/idempotency/filter/IdempotencyWebFilter.java @@ -21,14 +21,20 @@ import org.fireflyframework.web.idempotency.cache.IdempotencyCache; import org.fireflyframework.web.idempotency.config.IdempotencyProperties; import org.fireflyframework.web.idempotency.model.CachedResponse; +import org.fireflyframework.web.idempotency.scope.AuthenticatedIdempotencyScopeResolver; +import org.fireflyframework.web.idempotency.scope.IdempotencyScopeResolver; +import org.reactivestreams.Publisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.reactivestreams.Publisher; +import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; -import org.springframework.http.server.reactive.ServerHttpRequest; -import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequestDecorator; import org.springframework.http.server.reactive.ServerHttpResponseDecorator; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerMapping; @@ -39,322 +45,245 @@ import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CancellationException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; /** - * WebFilter that provides idempotency for all HTTP requests. - * It intercepts requests with an X-Idempotency-Key header and ensures that - * identical requests (with the same key) return the same response. - * The X-Idempotency-Key header is optional for all HTTP methods. - * - *

    This filter is automatically configured by {@link org.fireflyframework.web.idempotency.config.IdempotencyAutoConfiguration} - * when fireflyframework-cache is available on the classpath. It will not be created if the required - * cache dependencies are not available, preventing application startup failures.

    + * Bounded HTTP response reuse for authenticated, scoped mutations. Business transactions must + * still enforce their own durable idempotency: cache retention and in-flight coordination are + * not a cross-process transaction boundary. */ -public class IdempotencyWebFilter implements WebFilter { - +public class IdempotencyWebFilter implements WebFilter, Ordered { private static final Logger log = LoggerFactory.getLogger(IdempotencyWebFilter.class); - + private static final List MUTATIONS = List.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE); + private static final List FINGERPRINT_HEADERS = List.of("Content-Type", "Accept", "Content-Encoding", + "Accept-Encoding", "Accept-Language", "If-Match", "If-None-Match"); + private static final List REPLAY_HEADERS = List.of("Location", "ETag", "Content-Language", + "Content-Encoding", "Content-Location", "Content-Disposition", "Last-Modified", "Vary"); private final IdempotencyCache cache; - private final String idempotencyHeaderName; - private final Map> inFlight = new ConcurrentHashMap<>(); + private final IdempotencyProperties properties; + private final IdempotencyScopeResolver scopeResolver; + private final java.util.function.Supplier> handlerMappings; + private final Map inFlight = new ConcurrentHashMap<>(); - /** - * Creates a new IdempotencyWebFilter with the specified caching mechanism. - * - * @param properties the idempotency configuration properties - * @param cache the idempotency cache implementation - */ public IdempotencyWebFilter(IdempotencyProperties properties, IdempotencyCache cache) { - this.cache = cache; - this.idempotencyHeaderName = properties.getHeaderName(); + this(properties, cache, new AuthenticatedIdempotencyScopeResolver()); } - @Override - public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - ServerHttpRequest request = exchange.getRequest(); - log.debug("IdempotencyWebFilter.filter: Processing request {} {}", request.getMethod(), request.getPath()); - - // First check if this path is handled by a RestController or Router - return isHandledByController(exchange) - .flatMap(isControllerPath -> { - if (!isControllerPath) { - // Skip idempotency processing if the path is not handled by a controller - log.debug("IdempotencyWebFilter.filter: Skipping non-controller path {}", request.getPath()); - return chain.filter(exchange); - } - - // Check for idempotency header - List idempotencyKeyHeaders = request.getHeaders().get(idempotencyHeaderName); - if (idempotencyKeyHeaders == null || idempotencyKeyHeaders.isEmpty()) { - log.debug("IdempotencyWebFilter.filter: No {} header found", idempotencyHeaderName); - return chain.filter(exchange); - } - - String idempotencyKey = idempotencyKeyHeaders.get(0); - log.debug("IdempotencyWebFilter.filter: Found {}: {}", idempotencyHeaderName, idempotencyKey); - - if (idempotencyKey == null || idempotencyKey.trim().isEmpty()) { - log.debug("IdempotencyWebFilter.filter: Empty {} header", idempotencyHeaderName); - return sendBadRequest(exchange, "Invalid " + idempotencyHeaderName + " header"); - } - - // Check if idempotency is disabled for this endpoint - return isIdempotencyDisabled(exchange) - .flatMap(disabled -> { - if (disabled) { - // Skip idempotency processing if the endpoint has the DisableIdempotency annotation - log.debug("IdempotencyWebFilter.filter: Idempotency is disabled for this endpoint"); - return chain.filter(exchange); - } - - log.debug("IdempotencyWebFilter.filter: Checking cache for key: {}", idempotencyKey); - // Check if we have a cached response - return cache.get(idempotencyKey) - .hasElement() - .flatMap(hasCachedResponse -> { - if (hasCachedResponse) { - log.debug("IdempotencyWebFilter.filter: Found cached response for key: {}", idempotencyKey); - return cache.get(idempotencyKey) - .flatMap(cachedResponse -> returnCachedResponse(exchange, cachedResponse)); - } else { - log.debug("IdempotencyWebFilter.filter: No cached response found for key: {}", idempotencyKey); - Sinks.One newSink = Sinks.one(); - Sinks.One existingSink = inFlight.putIfAbsent(idempotencyKey, newSink); - if (existingSink != null) { - log.debug("IdempotencyWebFilter.filter: Another request in-flight for key: {}. Waiting for result.", idempotencyKey); - return existingSink.asMono() - .flatMap(cached -> returnCachedResponse(exchange, cached)); - } else { - log.debug("IdempotencyWebFilter.filter: This request will compute and emit result for key: {}", idempotencyKey); - return processThroughFilterChain(exchange, chain, idempotencyKey, newSink); - } - } - }); - }); - }); + public IdempotencyWebFilter(IdempotencyProperties properties, IdempotencyCache cache, IdempotencyScopeResolver scopeResolver) { + this(properties, cache, scopeResolver, List::of); } - private Mono processThroughFilterChain(ServerWebExchange exchange, WebFilterChain chain, String idempotencyKey, Sinks.One sink) { - log.debug("IdempotencyWebFilter.processThroughFilterChain: Processing request through filter chain for key: {}", idempotencyKey); - ServerHttpResponse originalResponse = exchange.getResponse(); - ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) { - @Override - public Mono writeWith(Publisher body) { - log.debug("IdempotencyWebFilter.writeWith: Writing response for key: {}", idempotencyKey); - // Convert the body to a Flux regardless of its type - Flux fluxBody = Flux.from(body); - - return super.writeWith(fluxBody.collectList().map(dataBuffers -> { - log.debug("IdempotencyWebFilter.writeWith: Collected response body for key: {}", idempotencyKey); - // Combine all DataBuffers to get the complete response body - DataBuffer joinedBuffer = exchange.getResponse().bufferFactory().join(dataBuffers); - byte[] content = new byte[joinedBuffer.readableByteCount()]; - joinedBuffer.read(content); - - // Create a copy of the buffer for writing to the response - DataBuffer copiedBuffer = exchange.getResponse().bufferFactory().wrap(content); - - // Store the response in the cache - MediaType contentType = exchange.getResponse().getHeaders().getContentType(); - String contentTypeStr = contentType != null ? contentType.toString() : null; - int statusCode = exchange.getResponse().getStatusCode() != null ? - exchange.getResponse().getStatusCode().value() : HttpStatus.OK.value(); - - log.debug("IdempotencyWebFilter.writeWith: Caching response for key: {}, status: {}, contentType: {}, bodyLength: {}", - idempotencyKey, statusCode, contentTypeStr, content.length); - - CachedResponse cachedResponse = new CachedResponse(statusCode, content, contentTypeStr); - cache.put(idempotencyKey, cachedResponse) - .doOnError(e -> { - // Log the error but don't fail the request - log.error("Failed to cache response for idempotency key {}: {}", idempotencyKey, e.getMessage(), e); - }) - .onErrorResume(e -> Mono.empty()) - .doOnSuccess(v -> log.debug("IdempotencyWebFilter.writeWith: Successfully cached response for key: {}", idempotencyKey)) - .subscribe(); + public IdempotencyWebFilter(IdempotencyProperties properties, IdempotencyCache cache, + IdempotencyScopeResolver scopeResolver, + java.util.function.Supplier> handlerMappings) { + this.properties = properties; + this.cache = cache; + this.scopeResolver = scopeResolver; + this.handlerMappings = handlerMappings; + } - // Notify any waiting requests and clear in-flight marker - try { - sink.tryEmitValue(cachedResponse); - } finally { - inFlight.remove(idempotencyKey); - } + /** Runs after Spring Security and the trusted principal bridge. */ + @Override public int getOrder() { return -80; } - return copiedBuffer; - }).flux()); + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + if (!properties.isEnabled() || properties.getNamespace() == null || properties.getNamespace().isBlank() || !MUTATIONS.contains(exchange.getRequest().getMethod())) { + return chain.filter(exchange); + } + List keys = exchange.getRequest().getHeaders().get(properties.getHeaderName()); + if (keys == null) return chain.filter(exchange); + return disabled(exchange).flatMap(disabled -> { + if (disabled) { + return chain.filter(exchange); } - }; + if (keys.size() != 1 || keys.getFirst() == null || keys.getFirst().isBlank() || keys.getFirst().length() > 256) { + return reject(exchange, HttpStatus.BAD_REQUEST, "Invalid " + properties.getHeaderName() + " header"); + } + // Resolve to a value before flatMap: a completed Mono must not fall through and execute twice. + return scopeResolver.resolve(exchange).filter(scope -> !scope.isBlank()) + .map(Optional::of).defaultIfEmpty(Optional.empty()) + .flatMap(scope -> scope.isEmpty() ? chain.filter(exchange) : readBody(exchange) + .flatMap(body -> process(exchange, chain, scope.get(), keys.getFirst(), body))); + }); + } - return chain.filter(exchange.mutate().response(decoratedResponse).build()) - .doOnSuccess(v -> log.debug("IdempotencyWebFilter.processThroughFilterChain: Successfully processed request through filter chain for key: {}", idempotencyKey)) - .onErrorResume(e -> { - // Notify waiters of the error and cleanup + private Mono readBody(ServerWebExchange exchange) { + return DataBufferUtils.join(exchange.getRequest().getBody(), properties.getMaxRequestBodyBytes()) + .map(buffer -> { try { - sink.tryEmitError(e); - } finally { - inFlight.remove(idempotencyKey); - } - // Handle JSON parsing errors and other exceptions gracefully - if (isJsonParsingError(e)) { - log.error("IdempotencyWebFilter.processThroughFilterChain: JSON parsing error for key: {}, returning BAD_REQUEST", idempotencyKey); - return sendBadRequest(exchange, "Invalid JSON format in request body"); - } - // For other errors, log and propagate them as is - log.error("IdempotencyWebFilter.processThroughFilterChain: Error processing request through filter chain for key: {}: {}", idempotencyKey, e.getMessage(), e); - return Mono.error(e); - }) - .doFinally(signal -> { - // In case there was no body written (e.g., empty response), ensure cleanup - inFlight.remove(idempotencyKey); - }); + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + return bytes; + } finally { DataBufferUtils.release(buffer); } + }).defaultIfEmpty(new byte[0]) + .onErrorMap(DataBufferLimitException.class, error -> + new org.springframework.web.server.ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "Idempotency request body limit exceeded", error)); } - private Mono returnCachedResponse(ServerWebExchange exchange, CachedResponse cachedResponse) { - log.debug("IdempotencyWebFilter.returnCachedResponse: Returning cached response with status: {}, contentType: {}, bodyLength: {}", - cachedResponse.getStatus(), - cachedResponse.getContentType(), - (cachedResponse.getBody() != null ? cachedResponse.getBody().length : 0)); - - ServerHttpResponse response = exchange.getResponse(); - response.setStatusCode(HttpStatus.valueOf(cachedResponse.getStatus())); - - if (cachedResponse.getContentType() != null) { - response.getHeaders().setContentType(MediaType.parseMediaType(cachedResponse.getContentType())); + private Mono process(ServerWebExchange exchange, WebFilterChain chain, String scope, String clientKey, byte[] body) { + var request = exchange.getRequest(); + String key = "v2:" + digest(parts(properties.getNamespace(), scope, request.getMethod().name(), request.getURI().getRawPath(), + Optional.ofNullable(request.getURI().getRawQuery()).orElse(""), clientKey)); + List fields = new java.util.ArrayList<>(); + for (String name : FINGERPRINT_HEADERS) { + List values = request.getHeaders().get(name); + fields.add(name); + fields.add(values == null ? null : Integer.toString(values.size())); + if (values != null) fields.addAll(values); } - - DataBuffer buffer = response.bufferFactory().wrap(cachedResponse.getBody()); - return response.writeWith(Mono.just(buffer)) - .doOnSuccess(v -> log.debug("IdempotencyWebFilter.returnCachedResponse: Successfully returned cached response")) - .doOnError(e -> log.error("IdempotencyWebFilter.returnCachedResponse: Error returning cached response: {}", e.getMessage(), e)); + String fingerprint = digest(parts(fields.toArray(String[]::new)), body); + return cache.get(key).map(Optional::of).defaultIfEmpty(Optional.empty()).flatMap(cached -> { + if (cached.isPresent()) return replay(exchange, cached.get(), fingerprint); + return Mono.defer(() -> { + PendingResponse pending = new PendingResponse(fingerprint, Sinks.one()); + PendingResponse existing = inFlight.putIfAbsent(key, pending); + if (existing != null) { + return existing.fingerprint().equals(fingerprint) + ? existing.response().asMono().flatMap(response -> replay(exchange, response, fingerprint)) + : reject(exchange, HttpStatus.CONFLICT, "Idempotency key was already used with a different request"); + } + // A previous owner may have finished between the first lookup and this claim. + return cache.get(key).map(Optional::of).defaultIfEmpty(Optional.empty()).flatMap(second -> { + if (second.isPresent()) { + pending.response().tryEmitValue(second.get()); + return replay(exchange, second.get(), fingerprint); + } + return execute(exchange, chain, key, body, pending); + }).doOnError(pending.response()::tryEmitError) + .doFinally(signal -> { + pending.response().tryEmitError(new CancellationException("Idempotent response unavailable")); + inFlight.remove(key, pending); + }); + }); + }); } - private Mono sendBadRequest(ServerWebExchange exchange, String message) { - ServerHttpResponse response = exchange.getResponse(); - response.setStatusCode(HttpStatus.BAD_REQUEST); - response.getHeaders().setContentType(MediaType.TEXT_PLAIN); - - byte[] bytes = message.getBytes(); - DataBuffer buffer = response.bufferFactory().wrap(bytes); - return response.writeWith(Mono.just(buffer)); + private Mono execute(ServerWebExchange exchange, WebFilterChain chain, String key, byte[] body, PendingResponse pending) { + AtomicReference captured = new AtomicReference<>(new byte[0]); + var response = new ServerHttpResponseDecorator(exchange.getResponse()) { + @Override public Mono writeWith(Publisher publisher) { + return DataBufferUtils.join(Flux.from(publisher), properties.getMaxResponseBodyBytes()) + .map(buffer -> { + try { + byte[] bytes = new byte[buffer.readableByteCount()]; buffer.read(bytes); return bytes; + } finally { DataBufferUtils.release(buffer); } + }).defaultIfEmpty(new byte[0]).flatMap(bytes -> { + captured.set(bytes); + return super.writeWith(Mono.just(bufferFactory().wrap(bytes))); + }); + } + @Override public Mono writeAndFlushWith(Publisher> publisher) { + return writeWith(Flux.from(publisher).concatMap(Flux::from)); + } + }; + var replayableRequest = new ServerHttpRequestDecorator(exchange.getRequest()) { + @Override public Flux getBody() { + return Flux.defer(() -> Flux.just(exchange.getResponse().bufferFactory().wrap(body))); + } + }; + return chain.filter(exchange.mutate().request(replayableRequest).response(response).build()) + .then(Mono.defer(() -> exchange.getResponse().setComplete())) + .then(Mono.defer(() -> { + // Commit callbacks finalize status and representation headers during delegate completion. + CachedResponse result = snapshot(exchange, pending.fingerprint(), captured.get()); + CachedResponse completed = result; + Mono save = HttpStatusCode.valueOf(result.getStatus()).is2xxSuccessful() && supportedVary(result) + ? cache.put(key, result).onErrorResume(error -> { + log.warn("HTTP idempotency cache write failed; durable business idempotency is required", error); + return Mono.empty(); + }) : Mono.empty(); + return save.doOnSuccess(ignored -> pending.response().tryEmitValue(completed)); + })); } - /** - * Checks if the given exception is related to JSON parsing errors. - * - * @param throwable the exception to check - * @return true if the exception is a JSON parsing error, false otherwise - */ - private boolean isJsonParsingError(Throwable throwable) { - if (throwable == null) { - return false; - } - - // Check for JSON parsing exceptions - String exceptionName = throwable.getClass().getSimpleName(); - String message = throwable.getMessage(); - - // Check for Jackson JSON parsing exceptions - if (exceptionName.contains("JsonParseException") || - exceptionName.contains("JsonProcessingException") || - exceptionName.contains("JsonMappingException")) { - return true; + private CachedResponse snapshot(ServerWebExchange exchange, String fingerprint, byte[] body) { + var response = exchange.getResponse(); + CachedResponse result = new CachedResponse(response.getStatusCode() == null ? 200 : response.getStatusCode().value(), + body, response.getHeaders().getFirst("Content-Type")); + result.setRequestFingerprint(fingerprint); + Map> headers = new LinkedHashMap<>(); + for (String name : REPLAY_HEADERS) { + List values = response.getHeaders().get(name); + if (values != null) headers.put(name, List.copyOf(values)); } - - // Check for Spring's DecodingException wrapping JSON errors - if (exceptionName.contains("DecodingException") && - message != null && message.contains("JSON decoding error")) { - return true; - } - - // Check for ServerWebInputException with JSON-related messages - if (exceptionName.contains("ServerWebInputException") && - message != null && message.contains("Failed to read HTTP message")) { - return true; - } - - // Recursively check the cause - return isJsonParsingError(throwable.getCause()); + result.setHeaders(headers); + return result; } - /** - * Checks if idempotency is disabled for the current endpoint. - * - * This method first tries to check if the handler method has the DisableIdempotency annotation. - * If the handler is not available yet (which can happen in a WebFilter), it falls back to - * checking the request path against known paths with the DisableIdempotency annotation. - * - * @param exchange the server web exchange - * @return a Mono that emits true if idempotency is disabled for this endpoint, false otherwise - */ - private Mono isIdempotencyDisabled(ServerWebExchange exchange) { - // First try to get the handler from the exchange attributes - Object handler = exchange.getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE); - - if (handler instanceof HandlerMethod) { - HandlerMethod handlerMethod = (HandlerMethod) handler; - boolean disabled = handlerMethod.hasMethodAnnotation(DisableIdempotency.class); - return Mono.just(disabled); + private Mono replay(ServerWebExchange exchange, CachedResponse cached, String fingerprint) { + if (!supportedVary(cached)) { + return reject(exchange, HttpStatus.CONFLICT, "Response varies on unsupported request headers; use durable business recovery"); } + if (!fingerprint.equals(cached.getRequestFingerprint())) { + return reject(exchange, HttpStatus.CONFLICT, "Idempotency key was already used with a different request"); + } + var response = exchange.getResponse(); + response.setStatusCode(HttpStatusCode.valueOf(cached.getStatus())); + if (cached.getContentType() != null) response.getHeaders().setContentType(MediaType.parseMediaType(cached.getContentType())); + if (cached.getHeaders() != null) cached.getHeaders().forEach((name, values) -> { + if (REPLAY_HEADERS.stream().anyMatch(allowed -> allowed.equalsIgnoreCase(name))) response.getHeaders().put(name, values); + }); + return response.writeWith(Mono.just(response.bufferFactory().wrap(cached.getBody()))); + } - // If the handler is not available yet, we need to use a different approach - // by checking the request path - return Mono.just(false) - .flatMap(disabled -> { - // Get the path from the request - String path = exchange.getRequest().getPath().value(); - - // In a real application, this could be a configurable list of paths - // or could use a more sophisticated matching mechanism - if (path.endsWith("-disabled") || path.contains("/disabled/")) { - return Mono.just(true); - } - - return Mono.just(disabled); - }); + private Mono reject(ServerWebExchange exchange, HttpStatus status, String message) { + exchange.getResponse().setStatusCode(status); + exchange.getResponse().getHeaders().setContentType(MediaType.TEXT_PLAIN); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(message.getBytes(StandardCharsets.UTF_8)))); } - /** - * Checks if the current path is handled by a RestController or Router. - * - * This method tries to get the handler from the exchange attributes and checks - * if it's a HandlerMethod (which indicates it's handled by a RestController) or - * a RouterFunction (which indicates it's handled by a Router). - * - * @param exchange the server web exchange - * @return a Mono that emits true if the path is handled by a controller, false otherwise - */ - private Mono isHandledByController(ServerWebExchange exchange) { - // Try to get the handler from the exchange attributes + private Mono disabled(ServerWebExchange exchange) { Object handler = exchange.getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE); + if (handler instanceof HandlerMethod method) return Mono.just(method.hasMethodAnnotation(DisableIdempotency.class)); + // Filters run before DispatcherHandler selects the controller. Resolve metadata lazily, + // without invoking the handler; path-name heuristics cannot honor method annotations. + return Flux.fromIterable(handlerMappings.get()).concatMap(mapping -> mapping.getHandler(exchange)).next() + .map(selected -> selected instanceof HandlerMethod method && method.hasMethodAnnotation(DisableIdempotency.class)) + .defaultIfEmpty(false); + } - // If the handler is a HandlerMethod, it's handled by a RestController - if (handler instanceof HandlerMethod) { - return Mono.just(true); - } - - // If the handler is not available yet or is not a HandlerMethod, - // we need to use a different approach to determine if it's a controller path - - // Get the path from the request - String path = exchange.getRequest().getPath().value(); - - // Check if the path starts with /api/ which is a common convention for REST APIs - // This is a simple heuristic and might need to be adjusted based on your application's conventions - if (path.startsWith("/api/")) { - return Mono.just(true); - } + private boolean supportedVary(CachedResponse response) { + if (response.getHeaders() == null) return true; + return response.getHeaders().entrySet().stream() + .filter(entry -> "Vary".equalsIgnoreCase(entry.getKey())) + .flatMap(entry -> entry.getValue().stream()) + .flatMap(value -> java.util.Arrays.stream(value.split(",", -1))) + .map(String::trim) + .allMatch(name -> FINGERPRINT_HEADERS.stream().anyMatch(selected -> selected.equalsIgnoreCase(name))); + } - // For test paths, consider them as controller paths - // This is to ensure that unit tests continue to work - if (path.startsWith("/test")) { - return Mono.just(true); + private byte[] parts(String... values) { + var bytes = new java.io.ByteArrayOutputStream(); + try (var output = new java.io.DataOutputStream(bytes)) { + output.writeInt(values.length); + for (String value : values) { + byte[] encoded = value == null ? null : value.getBytes(StandardCharsets.UTF_8); + output.writeInt(encoded == null ? -1 : encoded.length); + if (encoded != null) output.write(encoded); + } + return bytes.toByteArray(); + } catch (java.io.IOException impossible) { + throw new java.io.UncheckedIOException(impossible); } + } - // If we can't determine for sure, we'll assume it's not a controller path - // This is a conservative approach to avoid applying idempotency where it's not needed - return Mono.just(false); + private String digest(byte[]... values) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (byte[] value : values) digest.update(value); + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } } + + private record PendingResponse(String fingerprint, Sinks.One response) { } } diff --git a/modules/web/src/main/java/org/fireflyframework/web/idempotency/model/CachedResponse.java b/modules/web/src/main/java/org/fireflyframework/web/idempotency/model/CachedResponse.java index 9004cf74b..652a8931b 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/idempotency/model/CachedResponse.java +++ b/modules/web/src/main/java/org/fireflyframework/web/idempotency/model/CachedResponse.java @@ -31,6 +31,18 @@ @AllArgsConstructor public class CachedResponse { + /** SHA-256 request fingerprint; absent legacy entries must not be replayed. */ + private String requestFingerprint; + + /** Replayable representation headers only; authentication and cookies are excluded. */ + private java.util.Map> headers = java.util.Map.of(); + + public CachedResponse(int status, byte[] body, String contentType) { + this.status = status; + this.body = body; + this.contentType = contentType; + } + /** * HTTP status code of the cached response */ diff --git a/modules/web/src/main/java/org/fireflyframework/web/idempotency/scope/AuthenticatedIdempotencyScopeResolver.java b/modules/web/src/main/java/org/fireflyframework/web/idempotency/scope/AuthenticatedIdempotencyScopeResolver.java new file mode 100644 index 000000000..2bbd9fe62 --- /dev/null +++ b/modules/web/src/main/java/org/fireflyframework/web/idempotency/scope/AuthenticatedIdempotencyScopeResolver.java @@ -0,0 +1,70 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.web.idempotency.scope; + +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.security.spi.TenantResolverPort; +import org.springframework.security.core.Authentication; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.stream.Collectors; + +/** Uses validated Jafly authentication and tenant resolution, never caller-supplied identity headers. */ +public final class AuthenticatedIdempotencyScopeResolver implements IdempotencyScopeResolver { + + private final TenantResolverPort tenantResolver; + + public AuthenticatedIdempotencyScopeResolver() { + this(null); + } + + public AuthenticatedIdempotencyScopeResolver(TenantResolverPort tenantResolver) { + this.tenantResolver = tenantResolver; + } + + @Override + public Mono resolve(ServerWebExchange exchange) { + return exchange.getPrincipal().ofType(Authentication.class) + .filter(Authentication::isAuthenticated) + .map(Authentication::getPrincipal) + .ofType(SecurityPrincipal.class) + .filter(principal -> present(principal.issuer()) && present(principal.subject())) + .flatMap(principal -> resolveTenant(principal).filter(this::present) + .map(tenant -> namespace(principal, tenant))); + } + + private Mono resolveTenant(SecurityPrincipal principal) { + return tenantResolver == null ? Mono.justOrEmpty(principal.tenantId()) : tenantResolver.resolveTenant(principal); + } + + private String namespace(SecurityPrincipal principal, String tenant) { + return List.of(principal.issuer(), principal.subject(), tenant, + encodeSet(principal.authorities()), encodeSet(principal.scopes())).stream() + .map(value -> value.length() + ":" + value).collect(Collectors.joining()); + } + + private String encodeSet(java.util.Set values) { + return values.stream().sorted().map(value -> value.length() + ":" + value).collect(Collectors.joining()); + } + + private boolean present(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/modules/web/src/main/java/org/fireflyframework/web/idempotency/scope/IdempotencyScopeResolver.java b/modules/web/src/main/java/org/fireflyframework/web/idempotency/scope/IdempotencyScopeResolver.java new file mode 100644 index 000000000..61d9fdec6 --- /dev/null +++ b/modules/web/src/main/java/org/fireflyframework/web/idempotency/scope/IdempotencyScopeResolver.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.web.idempotency.scope; + +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** Resolves a trusted caller/tenant namespace. Empty disables HTTP response reuse for this request. */ +@FunctionalInterface +public interface IdempotencyScopeResolver { + Mono resolve(ServerWebExchange exchange); +} diff --git a/modules/web/src/main/java/org/fireflyframework/web/jackson/config/JsonNullableAutoConfiguration.java b/modules/web/src/main/java/org/fireflyframework/web/jackson/config/JsonNullableAutoConfiguration.java new file mode 100644 index 000000000..b5979364d --- /dev/null +++ b/modules/web/src/main/java/org/fireflyframework/web/jackson/config/JsonNullableAutoConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.web.jackson.config; + +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** Preserves generated DTO presence semantics through Boot's existing Jackson 2 builder. */ +@AutoConfiguration(beforeName = "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration") +@ConditionalOnClass(name = { + "com.fasterxml.jackson.databind.ObjectMapper", + "org.openapitools.jackson.nullable.JsonNullableModule" +}) +public class JsonNullableAutoConfiguration { + /** Leaves application-provided nullable modules and ObjectMapper beans in control. */ + @Bean + @ConditionalOnMissingBean(value = JsonNullableModule.class, name = "jsonNullableModule") + public JsonNullableModule jsonNullableModule() { + return new JsonNullableModule(); + } +} diff --git a/modules/web/src/main/java/org/fireflyframework/web/openapi/IdempotencyOpenAPICustomizer.java b/modules/web/src/main/java/org/fireflyframework/web/openapi/IdempotencyOpenAPICustomizer.java index 1e5e48cff..7dc30d7a8 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/openapi/IdempotencyOpenAPICustomizer.java +++ b/modules/web/src/main/java/org/fireflyframework/web/openapi/IdempotencyOpenAPICustomizer.java @@ -86,7 +86,7 @@ private void processOperation(Operation operation) { Parameter idempotencyKeyParam = new Parameter() .name(headerName) .in("header") - .description("Unique key for idempotent requests. If provided, ensures that identical requests with the same key will only be processed once.") + .description("Optional retry key for scoped POST, PUT, PATCH, and DELETE response reuse. Safe methods ignore it. Reuse requires configured trusted identity and namespace; changed input returns 409. Business effects require durable application idempotency.") .required(false) .schema(new io.swagger.v3.oas.models.media.StringSchema()); diff --git a/modules/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d8a679cb6..97d2b80bf 100644 --- a/modules/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/modules/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -6,3 +6,4 @@ org.fireflyframework.web.logging.config.PiiMaskingAutoConfiguration org.fireflyframework.web.conditions.config.ConditionsReportPropertiesAutoConfiguration org.fireflyframework.web.conditions.config.ConditionsReportAutoConfiguration org.fireflyframework.web.cors.config.CorsAutoConfiguration +org.fireflyframework.web.jackson.config.JsonNullableAutoConfiguration diff --git a/modules/web/src/test/java/org/fireflyframework/web/error/handler/ErrorDiagnosticMetadataTest.java b/modules/web/src/test/java/org/fireflyframework/web/error/handler/ErrorDiagnosticMetadataTest.java new file mode 100644 index 000000000..3090c4b9b --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/error/handler/ErrorDiagnosticMetadataTest.java @@ -0,0 +1,76 @@ +package org.fireflyframework.web.error.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.fireflyframework.web.error.config.ErrorHandlingProperties; +import org.fireflyframework.web.error.converter.ExceptionConverterService; +import org.fireflyframework.web.error.exceptions.BusinessException; +import org.fireflyframework.web.error.service.ErrorResponseNegotiator; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpStatus; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ErrorDiagnosticMetadataTest { + private static final Map METADATA = Map.of( + "sourceClass", "internal.TemplateService", "sourceMethod", "publish", + "sourceLine", 166, "sourceFile", "TemplateService.java", "exceptionType", "internal.Failure", + "causeType", "internal.DatabaseFailure", "causeMessage", "private diagnostic", + "operationId", "synthetic-operation", "retryAfter", 5); + + @ParameterizedTest + @ValueSource(strings = {"test", "prod", "production"}) + void defaultResponsesOmitDiagnosticCoordinatesButKeepBusinessMetadata(String profile) throws Exception { + verify(profile, false, false, false, false); + } + + @ParameterizedTest + @ValueSource(strings = {"prod", "production"}) + void productionOverridesEnabledDiagnosticFlags(String profile) throws Exception { + verify(profile, true, true, false, false); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void nonProductionDiagnosticsAndCauseFollowSeparateExplicitFlags(boolean cause) throws Exception { + verify("development", true, cause, true, cause); + } + + private void verify(String profile, boolean debug, boolean cause, boolean expectDebug, boolean expectCause) throws Exception { + var properties = new ErrorHandlingProperties(); + properties.setIncludeDebugInfo(debug); + properties.setIncludeExceptionCause(cause); + var environment = new MockEnvironment(); + environment.setActiveProfiles(profile); + var converter = mock(ExceptionConverterService.class); + var converted = new BusinessException(HttpStatus.BAD_REQUEST, "SYNTHETIC_INVALID", "Invalid request").withMetadata(METADATA); + when(converter.convertException(any())).thenReturn(converted); + var mapper = new ObjectMapper().findAndRegisterModules(); + var handler = new GlobalExceptionHandler(converter, Optional.empty(), properties, Optional.empty(), + Optional.empty(), environment, mapper, new ErrorResponseNegotiator(mapper), Optional.empty()); + for (Throwable error : java.util.List.of(converted, new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid request"))) { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/synthetic-template").build()); + handler.handle(exchange, error).block(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var metadata = mapper.readTree(exchange.getResponse().getBodyAsString().block()).path("metadata"); + assertThat(metadata.path("operationId").asText()).isEqualTo("synthetic-operation"); + assertThat(metadata.path("retryAfter").asInt()).isEqualTo(5); + for (String key : java.util.List.of("sourceClass", "sourceMethod", "sourceLine", "sourceFile", "exceptionType")) { + assertThat(metadata.has(key)).as(profile + ":" + error.getClass().getSimpleName() + ":" + key).isEqualTo(expectDebug); + } + assertThat(metadata.has("causeType")).isEqualTo(expectCause); + assertThat(metadata.has("causeMessage")).isEqualTo(expectCause); + assertThat(converted.getMetadata()).containsAllEntriesOf(METADATA); + } + } +} diff --git a/modules/web/src/test/java/org/fireflyframework/web/error/handler/ValidationRejectedValuePrivacyTest.java b/modules/web/src/test/java/org/fireflyframework/web/error/handler/ValidationRejectedValuePrivacyTest.java new file mode 100644 index 000000000..4b444faf0 --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/error/handler/ValidationRejectedValuePrivacyTest.java @@ -0,0 +1,91 @@ +package org.fireflyframework.web.error.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Optional; +import org.fireflyframework.web.error.config.ErrorHandlingProperties; +import org.fireflyframework.web.error.service.ErrorResponseNegotiator; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.slf4j.LoggerFactory; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.support.WebExchangeBindException; + +class ValidationRejectedValuePrivacyTest { + private static final String PRIVATE_INPUT = "synthetic-private-credential-must-not-be-echoed"; + + @ParameterizedTest + @CsvSource({ + "default,application/json", "development,application/json", + "prod,application/json", "production,application/json", + "default,application/problem+json", "development,application/problem+json", + "prod,application/problem+json", "production,application/problem+json" + }) + void validationKeepsActionableDiagnosticsWithoutEchoingInputOrLoggingTheBindingException( + String profile, String accept) throws Exception { + var mapper = new ObjectMapper().findAndRegisterModules(); + var properties = new ErrorHandlingProperties(); + properties.setIncludeDebugInfo(true); + properties.setIncludeExceptionCause(true); + var environment = new MockEnvironment(); + environment.setActiveProfiles(profile); + var handler = new GlobalExceptionHandler(null, Optional.empty(), properties, + Optional.empty(), Optional.empty(), environment, mapper, + new ErrorResponseNegotiator(mapper), Optional.empty()); + + var binding = new BeanPropertyBindingResult(new Input(PRIVATE_INPUT), "card"); + binding.addError(new FieldError("card", "credential", PRIVATE_INPUT, true, + new String[] {"NotAccepted"}, null, "Use the processor credential operation")); + binding.addError(new ObjectError("card", "The card request is invalid")); + var parameter = new MethodParameter( + ValidationRejectedValuePrivacyTest.class.getDeclaredMethod("submit", Input.class), 0); + var exception = new WebExchangeBindException(parameter, binding); + var exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/api/v1/cards") + .header("Accept", accept).build()); + + var logger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + var logs = new ListAppender(); + logs.start(); + logger.addAppender(logs); + try { + handler.handle(exchange, exception).block(); + } finally { + logger.detachAppender(logs); + logs.stop(); + } + String body = exchange.getResponse().getBodyAsString().block(); + var json = mapper.readTree(body); + var errors = accept.equals("application/json") ? json.path("errors") : json.path("extensions").path("errors"); + String diagnostic = logs.list.stream().map(event -> event.getFormattedMessage() + + (event.getThrowableProxy() == null ? "" : ThrowableProxyUtil.asString(event.getThrowableProxy()))) + .reduce("", (a, b) -> a + b); + + assertAll( + () -> assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(body).doesNotContain(PRIVATE_INPUT, "rejectedValue"), + () -> assertThat(diagnostic).doesNotContain(PRIVATE_INPUT), + () -> assertThat(errors).hasSize(2), + () -> assertThat(errors.get(0).path("field").asText()).isEqualTo("credential"), + () -> assertThat(errors.get(0).path("code").asText()).isEqualTo("NotAccepted"), + () -> assertThat(errors.get(0).path("message").asText()).isEqualTo("Use the processor credential operation"), + () -> assertThat(errors.get(0).path("metadata").path("bindingFailure").asBoolean()).isTrue()); + assertThat(binding.getFieldError().getRejectedValue()).isEqualTo(PRIVATE_INPUT); + } + + static void submit(Input input) {} + + record Input(String credential) {} +} diff --git a/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyAutoConfigurationTest.java b/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyAutoConfigurationTest.java new file mode 100644 index 000000000..0d3228d89 --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyAutoConfigurationTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.web.idempotency.filter; + +import org.fireflyframework.cache.factory.CacheManagerFactory; +import org.fireflyframework.cache.properties.CacheProperties; +import org.fireflyframework.web.idempotency.config.IdempotencyAutoConfiguration; +import org.fireflyframework.web.idempotency.config.IdempotencyProperties; +import org.fireflyframework.web.idempotency.scope.IdempotencyScopeResolver; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class IdempotencyAutoConfigurationTest { + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(IdempotencyAutoConfiguration.class)) + .withBean(CacheManagerFactory.class, () -> new CacheManagerFactory(new CacheProperties(), new com.fasterxml.jackson.databind.ObjectMapper(), null, null, null)); + + @Test void applicationNamespaceAndCustomTrustedResolverAreUsedByAutoConfiguredFilter() { + runner.withPropertyValues("spring.application.name=synthetic-auto-service") + .withBean(IdempotencyScopeResolver.class, () -> exchange -> Mono.just("synthetic-trusted-caller-and-tenant")) + .run(context -> { + assertThat(context).hasSingleBean(IdempotencyWebFilter.class); + assertThat(context.getBean(IdempotencyProperties.class).getNamespace()).isEqualTo("synthetic-auto-service"); + var filter = context.getBean(IdempotencyWebFilter.class); + AtomicInteger effects = new AtomicInteger(); + org.springframework.web.server.WebFilterChain handler = exchange -> { + effects.incrementAndGet(); exchange.getResponse().setStatusCode(HttpStatus.NO_CONTENT); + return exchange.getResponse().setComplete(); + }; + filter.filter(request(), handler).block(); filter.filter(request(), handler).block(); + assertThat(effects.get()).isEqualTo(1); + }); + } + + @Test void explicitEnvironmentNamespaceOverridesApplicationName() { + runner.withPropertyValues("spring.application.name=service", "firefly.web.idempotency.namespace=service:test") + .run(context -> assertThat(context.getBean(IdempotencyProperties.class).getNamespace()).isEqualTo("service:test")); + } + + private MockServerWebExchange request() { + return MockServerWebExchange.from(MockServerHttpRequest.post("/api/test").header("X-Idempotency-Key", "synthetic-key").body("{}")); + } +} diff --git a/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyHttpIntegrationTest.java b/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyHttpIntegrationTest.java new file mode 100644 index 000000000..8d08b261a --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyHttpIntegrationTest.java @@ -0,0 +1,431 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.web.idempotency.filter; + +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.web.idempotency.annotation.DisableIdempotency; +import org.fireflyframework.web.idempotency.cache.TestIdempotencyCache; +import org.fireflyframework.web.idempotency.config.IdempotencyProperties; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import reactor.core.publisher.Mono; +import reactor.netty.http.server.HttpServer; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class IdempotencyHttpIntegrationTest { + @Test void actualHttpControllerReceivesOwnBodyAndReadCannotReplayMutation() { + withServer((client, controller) -> { + assertThat(write(client, "/api/headers", "one").block()).isEqualTo("/api/headers:one"); + assertThat(write(client, "/api/headers", "one").block()).isEqualTo("/api/headers:one"); + assertThat(write(client, "/api/legs", "two").block()).isEqualTo("/api/legs:two"); + assertThat(client.post().uri("/api/headers").header("X-Idempotency-Key", "shared").bodyValue("different") + .exchangeToMono(response -> Mono.just(response.statusCode())).block()).isEqualTo(HttpStatus.CONFLICT); + assertThat(client.get().uri("/api/headers").header("X-Idempotency-Key", "shared").retrieve().bodyToMono(String.class).block()).isEqualTo("read-current"); + assertThat(controller.effects.get()).isEqualTo(2); + }); + } + + @Test void disabledControllerMethodActuallyBypassesResponseReuse() { + withServer((client, controller) -> { + assertThat(write(client, "/api/heartbeat", "one").block()).isEqualTo("heartbeat:1"); + assertThat(write(client, "/api/heartbeat", "one").block()).isEqualTo("heartbeat:2"); + assertThat(controller.effects.get()).isEqualTo(2); + }); + } + + @Test void disabledControllerIgnoresMalformedRetryHeaders() { + withServer((client, controller) -> { + var response = client.post().uri("/api/heartbeat").header("X-Idempotency-Key", "") + .exchangeToMono(result -> result.toEntity(String.class)).block(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("heartbeat:1"); + assertThat(controller.effects.get()).isEqualTo(1); + }); + } + + @Test void applicationEncodedResponseAndSafeMetadataSurviveReplay() { + withServer((client, controller) -> { + var original = client.post().uri("/api/encoded").header("X-Idempotency-Key", "encoded-key") + .header("Accept-Encoding", "gzip").bodyValue("report") + .exchangeToMono(response -> response.toEntity(byte[].class)).block(); + var replay = client.post().uri("/api/encoded").header("X-Idempotency-Key", "encoded-key") + .header("Accept-Encoding", "gzip").bodyValue("report") + .exchangeToMono(response -> response.toEntity(byte[].class)).block(); + assertThat(original.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(replay.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(original.getHeaders().getFirst("Content-Encoding")).isEqualTo("gzip"); + assertThat(replay.getHeaders().getFirst("Content-Encoding")).isEqualTo("gzip"); + assertThat(decodedGzip(original.getBody())).isEqualTo("synthetic report"); + assertThat(decodedGzip(replay.getBody())).isEqualTo("synthetic report"); + assertThat(replay.getBody()).containsExactly(original.getBody()); + for (String header : List.of("Vary", "Location", "ETag", "Content-Language", "Content-Location", "Content-Disposition", "Last-Modified")) { + assertThat(replay.getHeaders().get(header)).as(header).isEqualTo(original.getHeaders().get(header)); + } + assertThat(original.getHeaders().getFirst("Set-Cookie")).contains("synthetic-session"); + assertThat(original.getHeaders().getFirst("Authorization")).isEqualTo("Bearer synthetic-response-secret"); + assertThat(replay.getHeaders().containsHeader("Set-Cookie")).isFalse(); + assertThat(replay.getHeaders().containsHeader("Authorization")).isFalse(); + var changedNegotiation = client.post().uri("/api/encoded").header("X-Idempotency-Key", "encoded-key") + .header("Accept-Encoding", "identity").bodyValue("report") + .exchangeToMono(response -> response.toEntity(byte[].class)).block(); + assertThat(changedNegotiation.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(controller.effects.get()).isEqualTo(1); + }); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.MethodSource("changedHeaderValues") + void changingOnlyTheSecondSelectedHeaderValueConflicts(String name, String first, String second, String changed) { + withServer((client, controller) -> { + var original = client.post().uri("/api/representations").header("X-Idempotency-Key", "header-key") + .header(name, first, second).bodyValue("same-body") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + var retry = client.post().uri("/api/representations").header("X-Idempotency-Key", "header-key") + .header(name, first, changed).bodyValue("same-body") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + assertThat(original.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(retry.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(controller.effects.get()).isEqualTo(1); + }); + } + + static java.util.stream.Stream changedHeaderValues() { + return java.util.stream.Stream.of( + org.junit.jupiter.params.provider.Arguments.of("Accept", "text/plain", "application/xml", "application/json"), + org.junit.jupiter.params.provider.Arguments.of("If-Match", "\"v1\"", "\"v2\"", "\"v3\""), + org.junit.jupiter.params.provider.Arguments.of("If-None-Match", "\"v1\"", "\"v2\"", "\"v3\""), + org.junit.jupiter.params.provider.Arguments.of("Accept-Encoding", "gzip", "identity;q=0.5", "identity;q=0.7"), + org.junit.jupiter.params.provider.Arguments.of("Accept-Language", "en-US", "en;q=0.5", "fr;q=0.5")); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"X-Synthetic-Flavour", "*"}) + void unsupportedVaryFieldsDoNotCacheAnUnfingerprintedRepresentation(String vary) { + withServer((client, controller) -> { + var original = client.post().uri("/api/custom-vary").header("X-Idempotency-Key", "vary-key") + .header("X-Synthetic-Vary", vary).header("X-Synthetic-Flavour", "first").bodyValue("same-body") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + var retry = client.post().uri("/api/custom-vary").header("X-Idempotency-Key", "vary-key") + .header("X-Synthetic-Vary", vary).header("X-Synthetic-Flavour", "second").bodyValue("same-body") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + assertThat(original.getBody()).isEqualTo("first"); + assertThat(retry.getBody()).isEqualTo("second"); + assertThat(controller.effects.get()).isEqualTo(2); + }); + } + + @Test void selectedHeaderFieldBoundariesAreNotCollapsedIntoCommaJoinedText() { + withServer((client, controller) -> { + var original = client.post().uri("/api/representations").header("X-Idempotency-Key", "framing-key") + .header("If-Match", "\"v1\"", "\"v2\"").bodyValue("same-body") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + var retry = client.post().uri("/api/representations").header("X-Idempotency-Key", "framing-key") + .header("If-Match", "\"v1\", \"v2\"").bodyValue("same-body") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + assertThat(original.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(retry.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(controller.effects.get()).isEqualTo(1); + }); + } + + @Test void responseMetadataFinalizedBeforeCommitSurvivesEncodedReplay() { + withServer((client, controller) -> { + var original = client.post().uri("/api/late-encoded").header("X-Idempotency-Key", "late-gzip") + .header("Accept-Encoding", "gzip").bodyValue("same") + .exchangeToMono(response -> response.toEntity(byte[].class)).block(); + var replay = client.post().uri("/api/late-encoded").header("X-Idempotency-Key", "late-gzip") + .header("Accept-Encoding", "gzip").bodyValue("same") + .exchangeToMono(response -> response.toEntity(byte[].class)).block(); + assertThat(original.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(original.getHeaders().getFirst("Content-Encoding")).isEqualTo("gzip"); + assertThat(replay.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(replay.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.TEXT_PLAIN); + assertThat(replay.getHeaders().getFirst("Content-Encoding")).isEqualTo("gzip"); + assertThat(decodedGzip(original.getBody())).isEqualTo("synthetic report"); + assertThat(decodedGzip(replay.getBody())).isEqualTo("synthetic report"); + assertThat(replay.getHeaders().getFirst("Vary")).isEqualTo("Accept-Encoding"); + assertThat(controller.effects.get()).isEqualTo(1); + }); + } + + @Test void lateUnsupportedVaryCannotRetainTheOriginalVariant() { + withServer((client, controller) -> { + var original = client.post().uri("/api/late-vary").header("X-Idempotency-Key", "late-vary") + .header("X-Synthetic-Flavour", "first").bodyValue("same") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + var retry = client.post().uri("/api/late-vary").header("X-Idempotency-Key", "late-vary") + .header("X-Synthetic-Flavour", "second").bodyValue("same") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + assertThat(original.getHeaders().getFirst("Vary")).isEqualTo("X-Synthetic-Flavour"); + assertThat(retry.getBody()).isEqualTo("second"); + assertThat(controller.effects.get()).isEqualTo(2); + }); + } + + @Test void finalFailureStatusIsNotMistakenForThePrecommitSuccess() { + withServer((client, controller) -> { + var original = client.post().uri("/api/late-status").header("X-Idempotency-Key", "late-status").bodyValue("same") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + var retry = client.post().uri("/api/late-status").header("X-Idempotency-Key", "late-status").bodyValue("same") + .exchangeToMono(response -> response.toEntity(String.class)).block(); + assertThat(original.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(retry.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(controller.effects.get()).isEqualTo(2); + }); + } + + @Test void emptyResponseUsesFinalStatusAndMetadata() { + withServer((client, controller) -> { + var original = client.post().uri("/api/late-empty").header("X-Idempotency-Key", "late-empty") + .exchangeToMono(response -> response.toEntity(byte[].class)).block(); + var replay = client.post().uri("/api/late-empty").header("X-Idempotency-Key", "late-empty") + .exchangeToMono(response -> response.toEntity(byte[].class)).block(); + assertThat(original.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(replay.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(replay.getHeaders().getFirst("ETag")).isEqualTo("\"late-empty\""); + assertThat(replay.getBody()).isNullOrEmpty(); + assertThat(controller.effects.get()).isEqualTo(1); + }); + } + + @Test void pendingHttpDuplicateCannotConsumeVariantAddedAtCommit() { + withServer((client, controller) -> { + var original = pendingRequest(client, "vary", "first").toFuture(); + controller.commitEntered.asMono().block(Duration.ofSeconds(5)); + var duplicateSubscribed = reactor.core.publisher.Sinks.one(); + controller.cache.afterLookup = () -> duplicateSubscribed.tryEmitEmpty(); + var duplicate = pendingRequest(client, "vary", "second").toFuture(); + duplicateSubscribed.asMono().block(Duration.ofSeconds(5)); + controller.commitRelease.tryEmitEmpty(); + var first = Mono.fromFuture(original).block(Duration.ofSeconds(5)); + var second = Mono.fromFuture(duplicate).block(Duration.ofSeconds(5)); + assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(second.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(second.getBody()).doesNotContain("first variant"); + assertThat(controller.effects.get()).isEqualTo(1); + }); + } + + @Test void commitFailureCannotCompletePendingHttpDuplicateWithSuccess() { + withServer((client, controller) -> { + controller.failCommit.set(true); + var original = pendingRequest(client, "failure", "first").toFuture(); + controller.commitEntered.asMono().block(Duration.ofSeconds(5)); + var duplicateSubscribed = reactor.core.publisher.Sinks.one(); + controller.cache.afterLookup = () -> duplicateSubscribed.tryEmitEmpty(); + var duplicate = pendingRequest(client, "failure", "first").toFuture(); + duplicateSubscribed.asMono().block(Duration.ofSeconds(5)); + controller.commitRelease.tryEmitEmpty(); + assertThat(Mono.fromFuture(original).block(Duration.ofSeconds(5)).getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(Mono.fromFuture(duplicate).block(Duration.ofSeconds(5)).getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(controller.effects.get()).isEqualTo(1); + assertThat(pendingRequest(client, "failure", "first").block(Duration.ofSeconds(5)).getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(controller.effects.get()).isEqualTo(2); + }); + } + + private Mono> pendingRequest(WebClient client, String mode, String flavour) { + return client.post().uri("/api/late-pending").header("X-Idempotency-Key", "late-pending") + .header("X-Synthetic-Mode", mode).header("X-Synthetic-Flavour", flavour).bodyValue("same") + .exchangeToMono(response -> response.toEntity(String.class)); + } + + private String decodedGzip(byte[] bytes) { + try (var input = new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(bytes))) { + return new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException error) { + throw new AssertionError("The HTTP representation must decode as gzip", error); + } + } + + private Mono write(WebClient client, String uri, String body) { + return client.post().uri(uri).header("X-Idempotency-Key", "shared").bodyValue(body).retrieve().bodyToMono(String.class); + } + + private void withServer(java.util.function.BiConsumer assertion) { + try (var context = new AnnotationConfigApplicationContext(HttpFixture.class)) { + var handler = WebHttpHandlerBuilder.applicationContext(context).build(); + var server = HttpServer.create().host("127.0.0.1").port(0).handle(new ReactorHttpHandlerAdapter(handler)).bindNow(Duration.ofSeconds(10)); + try { + var client = WebClient.builder().clientConnector(new org.springframework.http.client.reactive.ReactorClientHttpConnector( + reactor.netty.http.client.HttpClient.create().compress(false))).baseUrl("http://127.0.0.1:" + server.port()).build(); + assertion.accept(client, context.getBean(SyntheticController.class)); + } finally { server.disposeNow(Duration.ofSeconds(10)); } + } + } + + @Configuration(proxyBeanMethods = false) + @EnableWebFlux + static class HttpFixture { + @Bean ObservedIdempotencyCache observedIdempotencyCache() { return new ObservedIdempotencyCache(); } + @Bean SyntheticController syntheticController(ObservedIdempotencyCache cache) { return new SyntheticController(cache); } + @Bean @org.springframework.core.annotation.Order(-90) WebFilter trustedSyntheticIdentity() { + var principal = SecurityPrincipal.builder().issuer("synthetic-issuer").subject("synthetic-actor").tenantId("synthetic-tenant").build(); + var auth = new UsernamePasswordAuthenticationToken(principal, "", List.of()); + return (exchange, chain) -> chain.filter(exchange.mutate().principal(Mono.just(auth)).build()); + } + @Bean IdempotencyWebFilter idempotencyWebFilter(org.springframework.beans.factory.ObjectProvider mappings, + ObservedIdempotencyCache cache) { + var properties = new IdempotencyProperties(); properties.setNamespace("synthetic-http-service"); + return new IdempotencyWebFilter(properties, cache, + new org.fireflyframework.web.idempotency.scope.AuthenticatedIdempotencyScopeResolver(), () -> mappings.orderedStream().toList()); + } + } + + static class ObservedIdempotencyCache extends TestIdempotencyCache { + volatile Runnable afterLookup = () -> { }; + @Override public Mono get(String key) { + Runnable observer = afterLookup; + return super.get(key).doAfterTerminate(observer); + } + } + + @RestController + static class SyntheticController { + final AtomicInteger effects = new AtomicInteger(); + final ObservedIdempotencyCache cache; + final reactor.core.publisher.Sinks.One commitEntered = reactor.core.publisher.Sinks.one(); + final reactor.core.publisher.Sinks.One commitRelease = reactor.core.publisher.Sinks.one(); + final java.util.concurrent.atomic.AtomicBoolean failCommit = new java.util.concurrent.atomic.AtomicBoolean(); + SyntheticController(ObservedIdempotencyCache cache) { this.cache = cache; } + @PostMapping({"/api/headers", "/api/legs"}) + Mono create(@RequestBody String body, org.springframework.http.server.reactive.ServerHttpRequest request) { + effects.incrementAndGet(); return Mono.just(request.getPath().value() + ":" + body); + } + @PostMapping("/api/encoded") + Mono> encoded() { + effects.incrementAndGet(); + try { + var output = new java.io.ByteArrayOutputStream(); + try (var gzip = new java.util.zip.GZIPOutputStream(output)) { + gzip.write("synthetic report".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + return Mono.just(org.springframework.http.ResponseEntity.status(HttpStatus.CREATED) + .contentType(org.springframework.http.MediaType.TEXT_PLAIN) + .header("Content-Encoding", "gzip").header("Vary", "Accept-Encoding", "Accept-Language") + .header("Content-Language", "en-US").header("Location", "/reports/synthetic") + .header("Content-Location", "/reports/synthetic/report.txt") + .header("Content-Disposition", "attachment; filename=report.txt") + .header("Last-Modified", "Wed, 21 Oct 2026 07:28:00 GMT").header("ETag", "\"v1\"") + .header("Set-Cookie", "synthetic-session=secret; HttpOnly") + .header("Authorization", "Bearer synthetic-response-secret").body(output.toByteArray())); + } catch (java.io.IOException error) { + return Mono.error(error); + } + } + @PostMapping("/api/representations") + Mono representation(@RequestBody String body, org.springframework.web.server.ServerWebExchange exchange) { + effects.incrementAndGet(); + exchange.getResponse().setStatusCode(HttpStatus.CREATED); + exchange.getResponse().getHeaders().setContentType(org.springframework.http.MediaType.TEXT_PLAIN); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap( + "selected representation".getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } + @PostMapping("/api/custom-vary") + Mono> customVary( + @RequestHeader("X-Synthetic-Vary") String vary, + @RequestHeader("X-Synthetic-Flavour") String flavour) { + effects.incrementAndGet(); + return Mono.just(org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).header("Vary", vary).body(flavour)); + } + @PostMapping("/api/late-encoded") + Mono lateEncoded(org.springframework.web.server.ServerWebExchange exchange) { + effects.incrementAndGet(); + exchange.getResponse().setStatusCode(HttpStatus.ACCEPTED); + exchange.getResponse().getHeaders().setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + exchange.getResponse().beforeCommit(() -> { + exchange.getResponse().setStatusCode(HttpStatus.CREATED); + exchange.getResponse().getHeaders().setContentType(org.springframework.http.MediaType.TEXT_PLAIN); + exchange.getResponse().getHeaders().set("Content-Encoding", "gzip"); + exchange.getResponse().getHeaders().set("Vary", "Accept-Encoding"); + return Mono.empty(); + }); + try { + var output = new java.io.ByteArrayOutputStream(); + try (var gzip = new java.util.zip.GZIPOutputStream(output)) { + gzip.write("synthetic report".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(output.toByteArray()))); + } catch (java.io.IOException error) { return Mono.error(error); } + } + @PostMapping("/api/late-vary") + Mono lateVary(@RequestHeader("X-Synthetic-Flavour") String flavour, org.springframework.web.server.ServerWebExchange exchange) { + effects.incrementAndGet(); + exchange.getResponse().setStatusCode(HttpStatus.CREATED); + exchange.getResponse().beforeCommit(() -> { + exchange.getResponse().getHeaders().set("Vary", "X-Synthetic-Flavour"); + return Mono.empty(); + }); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(flavour.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } + @PostMapping("/api/late-status") + Mono lateStatus(org.springframework.web.server.ServerWebExchange exchange) { + effects.incrementAndGet(); + exchange.getResponse().setStatusCode(HttpStatus.CREATED); + exchange.getResponse().beforeCommit(() -> { + exchange.getResponse().setStatusCode(HttpStatus.SERVICE_UNAVAILABLE); + return Mono.empty(); + }); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap("unavailable".getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } + @PostMapping("/api/late-empty") + Mono lateEmpty(org.springframework.web.server.ServerWebExchange exchange) { + effects.incrementAndGet(); + exchange.getResponse().beforeCommit(() -> { + exchange.getResponse().setStatusCode(HttpStatus.NO_CONTENT); + exchange.getResponse().getHeaders().set("ETag", "\"late-empty\""); + return Mono.empty(); + }); + return exchange.getResponse().setComplete(); + } + @PostMapping("/api/late-pending") + Mono latePending(@RequestHeader("X-Synthetic-Mode") String mode, + @RequestHeader("X-Synthetic-Flavour") String flavour, + org.springframework.web.server.ServerWebExchange exchange) { + effects.incrementAndGet(); + exchange.getResponse().setStatusCode(HttpStatus.CREATED); + exchange.getResponse().beforeCommit(() -> { + commitEntered.tryEmitEmpty(); + return commitRelease.asMono().then(Mono.defer(() -> { + if (failCommit.getAndSet(false)) return Mono.error(new IllegalStateException("synthetic commit failure")); + if (mode.equals("vary")) exchange.getResponse().getHeaders().set("Vary", "X-Synthetic-Flavour"); + return Mono.empty(); + })); + }); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap((flavour + " variant").getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } + @GetMapping("/api/headers") Mono read() { return Mono.just("read-current"); } + @PostMapping("/api/heartbeat") @DisableIdempotency + Mono heartbeat() { return Mono.just("heartbeat:" + effects.incrementAndGet()); } + } +} diff --git a/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyWebFilterTest.java b/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyWebFilterTest.java index 6eed01e80..bce1b13c0 100644 --- a/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyWebFilterTest.java +++ b/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/IdempotencyWebFilterTest.java @@ -64,6 +64,7 @@ void setUp() { // Create properties properties = new IdempotencyProperties(); properties.getCache().setTtlHours(24); + properties.setNamespace("unit-test"); // Create the filter idempotencyWebFilter = new IdempotencyWebFilter(properties, idempotencyCache); @@ -108,7 +109,7 @@ void shouldAllowFirstRequestWithIdempotencyKey() { } @Test - void shouldNotAllowSecondRequestWithSameIdempotencyKey() { + void shouldNotReplayUnscopedLegacyCacheEntry() { // Arrange String idempotencyKey = "test-key-2"; @@ -135,17 +136,12 @@ void shouldNotAllowSecondRequestWithSameIdempotencyKey() { idempotencyWebFilter.filter(exchange, filterChain).block(); // Verify that the filter chain was not called (request didn't reach the controller) - verify(filterChain, never()).filter(any()); - - // Verify that the response status is OK (from the cached response) - assertEquals(HttpStatus.OK, exchange.getResponse().getStatusCode()); - - // Verify that the response content type is APPLICATION_JSON (from the cached response) - assertEquals(MediaType.APPLICATION_JSON, exchange.getResponse().getHeaders().getContentType()); + verify(filterChain, times(1)).filter(any()); + org.junit.jupiter.api.Assertions.assertNull(exchange.getResponse().getStatusCode()); } @Test - void shouldApplyIdempotencyToGetMethod() { + void shouldAllowSafeGetWithoutResponseReuse() { // Arrange String idempotencyKey = "test-key-3"; MockServerHttpRequest request = MockServerHttpRequest diff --git a/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/ScopedIdempotencyWebFilterTest.java b/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/ScopedIdempotencyWebFilterTest.java new file mode 100644 index 000000000..0b5ecc124 --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/idempotency/filter/ScopedIdempotencyWebFilterTest.java @@ -0,0 +1,337 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.fireflyframework.web.idempotency.filter; + +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.web.idempotency.cache.TestIdempotencyCache; +import org.fireflyframework.web.idempotency.config.IdempotencyProperties; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScopedIdempotencyWebFilterTest { + private final TestIdempotencyCache cache = new TestIdempotencyCache(); + private final IdempotencyWebFilter filter = new IdempotencyWebFilter(properties("service-a"), cache); + private final AtomicInteger effects = new AtomicInteger(); + private final WebFilterChain writer = exchange -> { + effects.incrementAndGet(); + return respond(exchange, HttpStatus.CREATED, exchange.getRequest().getMethod() + " " + exchange.getRequest().getURI().getRawPath()); + }; + + @Test void sameKeyOnAnotherRouteExecutesThatRoute() { + run(request("POST", "/api/headers", "{}"), writer); + var second = request("POST", "/api/legs", "{}"); + run(second, writer); + assertThat(second.getResponse().getBodyAsString().block()).isEqualTo("POST /api/legs"); + assertThat(effects.get()).isEqualTo(2); + } + + @Test void safeReadDoesNotReplayOrPopulateMutationCache() { + run(request("POST", "/api/headers", "{}"), writer); + var get = request("GET", "/api/headers", ""); + run(get, writer); + run(request("GET", "/api/headers", ""), writer); + assertThat(get.getResponse().getBodyAsString().block()).isEqualTo("GET /api/headers"); + assertThat(effects.get()).isEqualTo(3); + } + + @Test void mutationMethodsAndQueryStringsHaveSeparateScopes() { + run(request("POST", "/api/headers?a=1", "{}"), writer); + run(request("PATCH", "/api/headers?a=1", "{}"), writer); + run(request("POST", "/api/headers?a=2", "{}"), writer); + assertThat(effects.get()).isEqualTo(3); + } + + @Test void changedPayloadReturnsConflictBeforeAnotherEffect() { + run(request("POST", "/api/headers", "{\"amount\":1}"), writer); + var changed = request("POST", "/api/headers", "{\"amount\":2}"); + run(changed, writer); + assertThat(changed.getResponse().getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void identicalPayloadReplaysResponseAndLocation() { + WebFilterChain create = exchange -> { + effects.incrementAndGet(); + exchange.getResponse().getHeaders().setLocation(java.net.URI.create("/api/headers/created")); + return respond(exchange, HttpStatus.CREATED, "created"); + }; + run(request("POST", "/api/headers", "{}"), create); + var duplicate = request("POST", "/api/headers", "{}"); + run(duplicate, create); + assertThat(duplicate.getResponse().getBodyAsString().block()).isEqualTo("created"); + assertThat(duplicate.getResponse().getHeaders().getLocation()).isEqualTo(java.net.URI.create("/api/headers/created")); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void authenticatedTenantAndSubjectAndIssuerAreIsolated() { + run(request("POST", "/api/headers", "{}", "alice", "tenant-a", "issuer-a"), writer); + run(request("POST", "/api/headers", "{}", "alice", "tenant-b", "issuer-a"), writer); + run(request("POST", "/api/headers", "{}", "bob", "tenant-a", "issuer-a"), writer); + run(request("POST", "/api/headers", "{}", "alice", "tenant-a", "issuer-b"), writer); + assertThat(effects.get()).isEqualTo(4); + } + + @Test void anonymousRequestsDoNotShareResponseCache() { + var first = MockServerWebExchange.from(MockServerHttpRequest.post("/api/headers").header("X-Idempotency-Key", "shared").build()); + var second = MockServerWebExchange.from(MockServerHttpRequest.post("/api/headers").header("X-Idempotency-Key", "shared").build()); + run(first, writer); run(second, writer); + assertThat(effects.get()).isEqualTo(2); + } + + @Test void requestBodyRemainsReadableByController() { + WebFilterChain echo = exchange -> org.springframework.core.io.buffer.DataBufferUtils.join(exchange.getRequest().getBody()) + .flatMap(buffer -> { + byte[] bytes = new byte[buffer.readableByteCount()]; buffer.read(bytes); + org.springframework.core.io.buffer.DataBufferUtils.release(buffer); + return respond(exchange, HttpStatus.OK, new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + }); + var exchange = request("POST", "/api/headers", "{\"amount\":1}"); + run(exchange, echo); + assertThat(exchange.getResponse().getBodyAsString().block()).isEqualTo("{\"amount\":1}"); + } + + @Test void emptySuccessIsReplayedAndConcurrentWaitersComplete() { + Sinks.One gate = Sinks.one(); + WebFilterChain empty = exchange -> { + effects.incrementAndGet(); + return gate.asMono().then(Mono.defer(() -> { exchange.getResponse().setStatusCode(HttpStatus.NO_CONTENT); return exchange.getResponse().setComplete(); })); + }; + var first = request("DELETE", "/api/headers/1", ""); + var duplicate = request("DELETE", "/api/headers/1", ""); + var a = filter.filter(first, empty).toFuture(); + var b = filter.filter(duplicate, empty).toFuture(); + gate.tryEmitEmpty(); + Mono.when(Mono.fromFuture(a), Mono.fromFuture(b)).block(Duration.ofSeconds(2)); + assertThat(duplicate.getResponse().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + var replay = request("DELETE", "/api/headers/1", ""); run(replay, empty); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void concurrentChangedPayloadIsRejectedBeforeFirstResponse() { + Sinks.One gate = Sinks.one(); + var first = filter.filter(request("POST", "/api/headers", "one"), exchange -> { + effects.incrementAndGet(); return gate.asMono().then(Mono.defer(() -> respond(exchange, HttpStatus.CREATED, "created"))); + }).toFuture(); + var conflicting = request("POST", "/api/headers", "two"); + run(conflicting, writer); + assertThat(conflicting.getResponse().getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + gate.tryEmitEmpty(); Mono.fromFuture(first).block(Duration.ofSeconds(2)); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void serverErrorIsNotPersistedForLaterRetry() { + run(request("POST", "/api/headers", "{}"), exchange -> respond(exchange, HttpStatus.SERVICE_UNAVAILABLE, "unavailable")); + var retry = request("POST", "/api/headers", "{}"); run(retry, writer); + assertThat(retry.getResponse().getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void separateApplicationsCannotReplayFromTheSameBackend() { + run(request("POST", "/api/headers", "{}"), writer); + var other = new IdempotencyWebFilter(properties("service-b"), cache); + other.filter(request("POST", "/api/headers", "{}"), writer).block(Duration.ofSeconds(2)); + assertThat(effects.get()).isEqualTo(2); + } + + @Test void missingApplicationNamespaceBypassesReuse() { + var unresolved = new IdempotencyWebFilter(new IdempotencyProperties(), cache); + unresolved.filter(request("POST", "/api/headers", "{}"), writer).block(Duration.ofSeconds(2)); + unresolved.filter(request("POST", "/api/headers", "{}"), writer).block(Duration.ofSeconds(2)); + assertThat(effects.get()).isEqualTo(2); + } + + @Test void suppliedTenantHeadersDoNotChangeTheTrustedIdentity() { + run(request("POST", "/api/headers", "{}"), writer); + var forged = request("POST", "/api/headers", "{}").mutate() + .request(builder -> builder.header("X-Tenant-Id", "another-tenant")).build(); + run(forged, writer); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void concurrentIdenticalRequestsExecuteOnce() { + Sinks.One gate = Sinks.one(); + WebFilterChain delayed = exchange -> { + effects.incrementAndGet(); + return gate.asMono().then(Mono.defer(() -> respond(exchange, HttpStatus.CREATED, "created"))); + }; + var requests = java.util.stream.IntStream.range(0, 8).mapToObj(i -> request("POST", "/api/headers", "{}")).toList(); + var futures = requests.stream().map(exchange -> filter.filter(exchange, delayed).toFuture()).toList(); + gate.tryEmitEmpty(); + Mono.when(futures.stream().map(Mono::fromFuture).toList()).block(Duration.ofSeconds(2)); + assertThat(effects.get()).isEqualTo(1); + assertThat(requests).allSatisfy(exchange -> assertThat(exchange.getResponse().getBodyAsString().block()).isEqualTo("created")); + } + + @Test void failureReleasesWaitersAndAllowsLaterRetry() { + Sinks.One gate = Sinks.one(); + var first = filter.filter(request("POST", "/api/headers", "{}"), exchange -> gate.asMono().then(Mono.error(new IllegalStateException("synthetic failure")))).toFuture(); + var second = filter.filter(request("POST", "/api/headers", "{}"), writer).toFuture(); + gate.tryEmitEmpty(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> Mono.fromFuture(first).block(Duration.ofSeconds(2))).hasMessage("synthetic failure"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> Mono.fromFuture(second).block(Duration.ofSeconds(2))).hasMessage("synthetic failure"); + run(request("POST", "/api/headers", "{}"), writer); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void cancellationReleasesExistingWaiters() { + var first = filter.filter(request("POST", "/api/headers", "{}"), exchange -> Mono.never()).toFuture(); + var second = filter.filter(request("POST", "/api/headers", "{}"), writer).toFuture(); + first.cancel(true); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> Mono.fromFuture(second).block(Duration.ofSeconds(2))).isInstanceOf(java.util.concurrent.CancellationException.class); + run(request("POST", "/api/headers", "{}"), writer); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void oversizedRequestCannotReachTheHandler() { + var small = properties("small"); small.setMaxRequestBodyBytes(3); + var bounded = new IdempotencyWebFilter(small, cache); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> bounded.filter(request("POST", "/api/headers", "four"), writer).block(Duration.ofSeconds(2))) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class) + .satisfies(error -> assertThat(((org.springframework.web.server.ResponseStatusException) error).getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE)); + assertThat(effects.get()).isZero(); + } + + @Test void representationAndPreconditionChangesConflict() { + run(request("POST", "/api/headers", "{}"), writer); + var changed = request("POST", "/api/headers", "{}").mutate().request(builder -> builder.header("If-Match", "version-2")).build(); + run(changed, writer); + assertThat(changed.getResponse().getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void differentAuthoritySetsCannotCollideThroughListFormatting() { + var actor = SecurityPrincipal.builder().issuer("issuer-a").subject("alice").tenantId("tenant-a") + .authorities(java.util.Set.of("read, write")).build(); + var different = actor.toBuilder().authorities(java.util.Set.of("read", "write")).build(); + var first = request("POST", "/api/headers", "{}").mutate().principal(Mono.just(new UsernamePasswordAuthenticationToken(actor, "", List.of()))).build(); + var second = request("POST", "/api/headers", "{}").mutate().principal(Mono.just(new UsernamePasswordAuthenticationToken(different, "", List.of()))).build(); + run(first, writer); run(second, writer); + assertThat(effects.get()).isEqualTo(2); + } + + @Test void trustedTenantResolverSupportsPrincipalsWhoseTenantLivesInValidatedClaims() { + var scope = new org.fireflyframework.web.idempotency.scope.AuthenticatedIdempotencyScopeResolver(principal -> + Mono.justOrEmpty(principal.claim("validated-tenant", String.class))); + var resolved = new IdempotencyWebFilter(properties("service-a"), cache, scope); + var actor = SecurityPrincipal.builder().issuer("issuer-a").subject("alice") + .claims(java.util.Map.of("validated-tenant", "tenant-a")).build(); + var auth = new UsernamePasswordAuthenticationToken(actor, "", List.of()); + var first = request("POST", "/api/headers", "{}").mutate().principal(Mono.just(auth)).build(); + var second = request("POST", "/api/headers", "{}").mutate().principal(Mono.just(auth)).build(); + resolved.filter(first, writer).block(); resolved.filter(second, writer).block(); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void legacyRawKeyEntriesAreNeverReplayed() { + cache.putSync("shared", new org.fireflyframework.web.idempotency.model.CachedResponse(201, "legacy".getBytes(), "text/plain")); + var request = request("POST", "/api/headers", "{}"); run(request, writer); + assertThat(request.getResponse().getBodyAsString().block()).isEqualTo("POST /api/headers"); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void anotherFilterInstanceReadsTheSameBackendEntry() { + run(request("POST", "/api/headers", "{}"), writer); + var restarted = new IdempotencyWebFilter(properties("service-a"), cache); + var duplicate = request("POST", "/api/headers", "{}"); + restarted.filter(duplicate, writer).block(Duration.ofSeconds(2)); + assertThat(duplicate.getResponse().getBodyAsString().block()).isEqualTo("POST /api/headers"); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void awaitingCacheWriteKeepsConcurrentRequestsOnTheOriginalEffect() { + Sinks.One persist = Sinks.one(); + var delayedCache = new org.fireflyframework.web.idempotency.cache.IdempotencyCache() { + public Mono get(String key) { return cache.get(key); } + public Mono put(String key, org.fireflyframework.web.idempotency.model.CachedResponse response) { + return persist.asMono().then(Mono.defer(() -> cache.put(key, response))); + } + }; + var waiting = new IdempotencyWebFilter(properties("service-a"), delayedCache); + var first = waiting.filter(request("POST", "/api/headers", "{}"), writer).toFuture(); + var duplicate = waiting.filter(request("POST", "/api/headers", "{}"), writer).toFuture(); + assertThat(first).isNotDone(); assertThat(duplicate).isNotDone(); + assertThat(effects.get()).isEqualTo(1); + persist.tryEmitEmpty(); + Mono.when(Mono.fromFuture(first), Mono.fromFuture(duplicate)).block(Duration.ofSeconds(2)); + run(request("POST", "/api/headers", "{}"), writer); + assertThat(effects.get()).isEqualTo(1); + } + + @Test void oversizedResponseDoesNotLeaveAnInFlightClaim() { + var small = properties("small-response"); small.setMaxResponseBodyBytes(3); + var bounded = new IdempotencyWebFilter(small, cache); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> bounded.filter(request("POST", "/api/headers", "{}"), writer).block(Duration.ofSeconds(2))) + .isInstanceOf(org.springframework.core.io.buffer.DataBufferLimitException.class); + assertThat(effects.get()).isEqualTo(1); + bounded.filter(request("POST", "/api/headers", "{}"), exchange -> respond(exchange, HttpStatus.OK, "ok")).block(Duration.ofSeconds(2)); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"X-Synthetic-Flavour", "*"}) + void concurrentWaiterCannotReplayAnUnsupportedVaryRepresentation(String vary) { + Sinks.One gate = Sinks.one(); + WebFilterChain varying = exchange -> { + effects.incrementAndGet(); + exchange.getResponse().getHeaders().set("Vary", vary); + return gate.asMono().then(Mono.defer(() -> respond(exchange, HttpStatus.CREATED, "first variant"))); + }; + var original = request("POST", "/api/headers", "{}"); + var duplicate = request("POST", "/api/headers", "{}"); + var first = filter.filter(original, varying).toFuture(); + var second = filter.filter(duplicate, varying).toFuture(); + gate.tryEmitEmpty(); + Mono.when(Mono.fromFuture(first), Mono.fromFuture(second)).block(Duration.ofSeconds(2)); + assertThat(original.getResponse().getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(duplicate.getResponse().getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(effects.get()).isEqualTo(1); + run(request("POST", "/api/headers", "{}"), writer); + assertThat(effects.get()).isEqualTo(2); + } + + private IdempotencyProperties properties(String namespace) { + var properties = new IdempotencyProperties(); properties.setNamespace(namespace); return properties; + } + + private void run(ServerWebExchange exchange, WebFilterChain chain) { filter.filter(exchange, chain).block(Duration.ofSeconds(2)); } + private MockServerWebExchange request(String method, String uri, String body) { return request(method, uri, body, "alice", "tenant-a", "issuer-a"); } + private MockServerWebExchange request(String method, String uri, String body, String subject, String tenant, String issuer) { + var principal = SecurityPrincipal.builder().subject(subject).tenantId(tenant).issuer(issuer).build(); + return MockServerWebExchange.builder(MockServerHttpRequest.method(org.springframework.http.HttpMethod.valueOf(method), uri) + .header("X-Idempotency-Key", "shared").contentType(MediaType.APPLICATION_JSON).body(body)) + .principal(new UsernamePasswordAuthenticationToken(principal, "", List.of())).build(); + } + private Mono respond(ServerWebExchange exchange, HttpStatus status, String body) { + exchange.getResponse().setStatusCode(status); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } +} diff --git a/modules/web/src/test/java/org/fireflyframework/web/jackson/config/JsonNullableAutoConfigurationTest.java b/modules/web/src/test/java/org/fireflyframework/web/jackson/config/JsonNullableAutoConfigurationTest.java new file mode 100644 index 000000000..7aee94081 --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/jackson/config/JsonNullableAutoConfigurationTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.web.jackson.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.junit.jupiter.api.Test; +import org.openapitools.jackson.nullable.JsonNullable; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class JsonNullableAutoConfigurationTest { + private static final String AUTO_CONFIGURATION = + "org.fireflyframework.web.jackson.config.JsonNullableAutoConfiguration"; + + private ApplicationContextRunner runner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + Jackson2AutoConfiguration.class, JsonNullableAutoConfiguration.class)); + } + + @Test + void declaredAutoConfigurationIsDiscoverable() { + assertThat(ImportCandidates.load(AutoConfiguration.class, getClass().getClassLoader())) + .contains(AUTO_CONFIGURATION); + } + + @Test + void defaultModulePreservesUndefinedNullAndValuesThroughBootBuilder() { + runner().run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(JsonNullableModule.class); + ObjectMapper mapper = context.getBean(Jackson2ObjectMapperBuilder.class).build(); + var payload = new Payload(); + assertThat(mapper.writeValueAsString(payload)).isEqualTo("{}"); + payload.content = JsonNullable.of(null); + assertThat(mapper.writeValueAsString(payload)).isEqualTo("{\"content\":null}"); + assertThat(mapper.readValue("{\"content\":null}", Payload.class).content) + .isEqualTo(JsonNullable.of(null)); + payload.content = JsonNullable.of(List.of("synthetic-value")); + assertThat(mapper.writeValueAsString(payload)) + .isEqualTo("{\"content\":[\"synthetic-value\"]}"); + assertThat(mapper.readValue("{}", Payload.class).content.isPresent()).isFalse(); + assertThat(mapper.readValue("{\"content\":[\"synthetic-value\"]}", Payload.class).content.get()) + .containsExactly("synthetic-value"); + }); + } + + @Test + void unrelatedModuleDoesNotDisableNullableSupport() { + runner().withUserConfiguration(UnrelatedModule.class).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(JsonNullableModule.class); + assertThat(context.getBeansOfType(SimpleModule.class).values()) + .contains(context.getBean("projectOtherModule", SimpleModule.class)); + }); + } + + @Test + void plainProjectModuleWinsInEitherUserConfigurationOrder() { + for (boolean reverse : new boolean[] {false, true}) { + var classes = reverse + ? new Class[] {ProjectModule.class, UnrelatedModule.class} + : new Class[] {UnrelatedModule.class, ProjectModule.class}; + runner().withUserConfiguration(classes).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(JsonNullableModule.class); + assertThat(context.getBean(JsonNullableModule.class)).isSameAs(ProjectModule.MODULE); + assertThat(context.containsBean("jsonNullableModule")).isFalse(); + }); + } + } + + @Test + void conventionallyNamedProjectModuleMayDeclareTheGenericModuleReturnType() { + for (boolean reverse : new boolean[] {false, true}) { + var classes = reverse + ? new Class[] {GenericNamedProjectModule.class, UnrelatedModule.class} + : new Class[] {UnrelatedModule.class, GenericNamedProjectModule.class}; + runner().withUserConfiguration(classes).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(JsonNullableModule.class); + assertThat(context.getBean("jsonNullableModule")) + .isSameAs(GenericNamedProjectModule.MODULE); + }); + } + } + + @Test + void existingApplicationMapperIsNeverReplacedOrMutated() { + runner().withUserConfiguration(ProjectMapper.class).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(JsonNullableModule.class) + .hasSingleBean(ObjectMapper.class); + assertThat(context.getBean(ObjectMapper.class)).isSameAs(ProjectMapper.MAPPER); + assertThat(ProjectMapper.MAPPER.getRegisteredModuleIds()).isEmpty(); + }); + } + + @Test + void absentOptionalNullableLibraryDoesNotCreateTheModule() { + runner().withClassLoader(new FilteredClassLoader("org.openapitools.jackson.nullable")) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.containsBean("jsonNullableModule")).isFalse(); + }); + } + + @Test + void absentJackson2DoesNotLoadTheIntegration() { + runner().withClassLoader(new FilteredClassLoader("com.fasterxml.jackson")) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.containsBean("jsonNullableModule")).isFalse(); + }); + } + + public static class Payload { + public JsonNullable> content = JsonNullable.undefined(); + } + + @Configuration(proxyBeanMethods = false) + static class ProjectModule { + static final JsonNullableModule MODULE = new JsonNullableModule(); + @Bean JsonNullableModule projectNullableModule() { return MODULE; } + } + + @Configuration(proxyBeanMethods = false) + static class UnrelatedModule { + @Bean SimpleModule projectOtherModule() { return new SimpleModule("project-other"); } + } + + @Configuration(proxyBeanMethods = false) + static class GenericNamedProjectModule { + static final JsonNullableModule MODULE = new JsonNullableModule(); + @Bean com.fasterxml.jackson.databind.Module jsonNullableModule() { return MODULE; } + } + + @Configuration(proxyBeanMethods = false) + static class ProjectMapper { + static final ObjectMapper MAPPER = new ObjectMapper(); + @Bean ObjectMapper projectMapper() { return MAPPER; } + } +} diff --git a/modules/webhooks/README.md b/modules/webhooks/README.md index 4c441fdb2..0fcbfb67d 100644 --- a/modules/webhooks/README.md +++ b/modules/webhooks/README.md @@ -32,7 +32,7 @@ This artifact has `pom` packaging and groups child projects. Select a concrete c org.fireflyframework fireflyframework-webhooks-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/webhooks/fireflyframework-webhooks-core/README.md b/modules/webhooks/fireflyframework-webhooks-core/README.md index 35bb89581..588e35ca3 100644 --- a/modules/webhooks/fireflyframework-webhooks-core/README.md +++ b/modules/webhooks/fireflyframework-webhooks-core/README.md @@ -20,7 +20,7 @@ Validate/enrich the inbound verification-provider request and publish its config org.fireflyframework fireflyframework-webhooks-core - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/webhooks/fireflyframework-webhooks-core/pom.xml b/modules/webhooks/fireflyframework-webhooks-core/pom.xml index 67ec13b10..64ab547bf 100644 --- a/modules/webhooks/fireflyframework-webhooks-core/pom.xml +++ b/modules/webhooks/fireflyframework-webhooks-core/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-webhooks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/webhooks/fireflyframework-webhooks-interfaces/README.md b/modules/webhooks/fireflyframework-webhooks-interfaces/README.md index 3f1c5b9e9..31ed535b2 100644 --- a/modules/webhooks/fireflyframework-webhooks-interfaces/README.md +++ b/modules/webhooks/fireflyframework-webhooks-interfaces/README.md @@ -20,7 +20,7 @@ Carry the provider name, JSON payload, event ID, and request metadata from verif org.fireflyframework fireflyframework-webhooks-interfaces - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/webhooks/fireflyframework-webhooks-interfaces/pom.xml b/modules/webhooks/fireflyframework-webhooks-interfaces/pom.xml index 845723936..6f0fea646 100644 --- a/modules/webhooks/fireflyframework-webhooks-interfaces/pom.xml +++ b/modules/webhooks/fireflyframework-webhooks-interfaces/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-webhooks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/webhooks/fireflyframework-webhooks-processor/README.md b/modules/webhooks/fireflyframework-webhooks-processor/README.md index 50a372f2c..4260e28d5 100644 --- a/modules/webhooks/fireflyframework-webhooks-processor/README.md +++ b/modules/webhooks/fireflyframework-webhooks-processor/README.md @@ -20,7 +20,7 @@ Implement the worker that turns a verified provider event into an application-st org.fireflyframework fireflyframework-webhooks-processor - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/webhooks/fireflyframework-webhooks-processor/pom.xml b/modules/webhooks/fireflyframework-webhooks-processor/pom.xml index 9257cac4a..27e1235e3 100644 --- a/modules/webhooks/fireflyframework-webhooks-processor/pom.xml +++ b/modules/webhooks/fireflyframework-webhooks-processor/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-webhooks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/webhooks/fireflyframework-webhooks-sdk/README.md b/modules/webhooks/fireflyframework-webhooks-sdk/README.md index 4c3211e47..6b207f45d 100644 --- a/modules/webhooks/fireflyframework-webhooks-sdk/README.md +++ b/modules/webhooks/fireflyframework-webhooks-sdk/README.md @@ -20,7 +20,7 @@ An integration client can use the generated ingestion contract when sending prov org.fireflyframework fireflyframework-webhooks-sdk - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/webhooks/fireflyframework-webhooks-sdk/pom.xml b/modules/webhooks/fireflyframework-webhooks-sdk/pom.xml index 9fdaaac4a..a68c96583 100644 --- a/modules/webhooks/fireflyframework-webhooks-sdk/pom.xml +++ b/modules/webhooks/fireflyframework-webhooks-sdk/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-webhooks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/webhooks/fireflyframework-webhooks-web/README.md b/modules/webhooks/fireflyframework-webhooks-web/README.md index abe1e0c60..365bddf57 100644 --- a/modules/webhooks/fireflyframework-webhooks-web/README.md +++ b/modules/webhooks/fireflyframework-webhooks-web/README.md @@ -20,7 +20,7 @@ Expose the verification-provider ingestion endpoint through this service after i org.fireflyframework fireflyframework-webhooks-web - 26.10.21-SNAPSHOT + 26.10.22 ``` diff --git a/modules/webhooks/fireflyframework-webhooks-web/pom.xml b/modules/webhooks/fireflyframework-webhooks-web/pom.xml index 4e87203ef..040376121 100644 --- a/modules/webhooks/fireflyframework-webhooks-web/pom.xml +++ b/modules/webhooks/fireflyframework-webhooks-web/pom.xml @@ -6,7 +6,7 @@ org.fireflyframework fireflyframework-webhooks - 26.10.21-SNAPSHOT + 26.10.22 ../pom.xml diff --git a/modules/webhooks/pom.xml b/modules/webhooks/pom.xml index 8554fecda..530afa03b 100644 --- a/modules/webhooks/pom.xml +++ b/modules/webhooks/pom.xml @@ -7,13 +7,13 @@ org.fireflyframework fireflyframework-parent - 26.10.21-SNAPSHOT + 26.10.22 ../../build/parent/pom.xml org.fireflyframework fireflyframework-webhooks - 26.10.21-SNAPSHOT + 26.10.22 pom Firefly Framework - Webhooks Library diff --git a/pom.xml b/pom.xml index 11ce3f0fb..564aed5b0 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.fireflyframework fireflyframework-jafly - 26.10.21-SNAPSHOT + 26.10.22 pom Jafly - Java Framework Reactor Unified Java framework build. Consumers use fireflyframework-parent or fireflyframework-bom. @@ -12,6 +12,7 @@ build/parent modules/agentic-bridge modules/backoffice + modules/batch modules/cache modules/cache-hazelcast modules/cache-jcache diff --git a/scripts/github_packages.py b/scripts/github_packages.py index 3fab11e60..9f208937a 100644 --- a/scripts/github_packages.py +++ b/scripts/github_packages.py @@ -446,7 +446,7 @@ def main(): args = parser.parse_args() if args.command == 'plan': tools = release_tools(args.root) - projects = tools.check_reactor(args.root, expected=86) + projects = tools.check_reactor(args.root, expected=87) association_path = args.associations or args.root / 'scripts/package-associations.jsonl' associations = [json.loads(line) for line in association_path.read_text().splitlines() if line.strip()] homes = build_homes(projects, associations) diff --git a/scripts/release_tools.py b/scripts/release_tools.py index 0c4825a38..34ce3acec 100644 --- a/scripts/release_tools.py +++ b/scripts/release_tools.py @@ -209,7 +209,7 @@ def verify_remote(inventory, url, username=None, token=None, attempts=1, delay=0 homes = Path(__file__).with_name('package-homes.json') if urllib.parse.urlsplit(url).hostname == 'maven.pkg.github.com': from verify_github_packages import verify_github_layout - projects = check_reactor(Path(__file__).resolve().parents[1], expected=86) + projects = check_reactor(Path(__file__).resolve().parents[1], expected=87) return verify_github_layout(inventory, url, projects, homes, username, token, attempts, delay) if attempts < 1 or delay < 0: raise ValueError('attempts must be positive and delay nonnegative') @@ -246,14 +246,14 @@ def main(): check.add_argument('--tag') check.add_argument('--remote', help='Require the remote tag to resolve to the checkout commit') check.add_argument('--snapshot', action='store_true') - check.add_argument('--expected', type=int, default=86) + check.add_argument('--expected', type=int, default=87) check.add_argument('--output', type=Path) inventory = commands.add_parser('inventory') inventory.add_argument('--root', type=Path, default=Path('.')) source = inventory.add_mutually_exclusive_group(required=True) source.add_argument('--repository', type=Path) source.add_argument('--bundle', type=Path) - inventory.add_argument('--expected', type=int, default=86) + inventory.add_argument('--expected', type=int, default=87) inventory.add_argument('--output', type=Path, required=True) verify = commands.add_parser('verify') verify.add_argument('--manifest', type=Path, required=True) diff --git a/scripts/verify_grpc_alignment.py b/scripts/verify_grpc_alignment.py new file mode 100644 index 000000000..74bac2bb0 --- /dev/null +++ b/scripts/verify_grpc_alignment.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Check that the published generator property and resolved gRPC runtime agree.""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from xml.sax.saxutils import escape + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--maven', default='mvn') + parser.add_argument('--settings', type=Path) + parser.add_argument('--repository', type=Path) + parser.add_argument('--report', type=Path) + args = parser.parse_args() + parent = Path(__file__).resolve().parents[1] / 'build/parent/pom.xml' + namespace = {'m': 'http://maven.apache.org/POM/4.0.0'} + version = ET.parse(parent).findtext('m:version', namespaces=namespace) + with tempfile.TemporaryDirectory(prefix='jafly-grpc-alignment-') as temporary: + root = Path(temporary).resolve() + dependencies = ''.join( + 'io.grpc' + artifact + + '${grpc.version}' + for artifact in ('grpc-netty-shaded', 'grpc-protobuf', 'grpc-stub')) + (root / 'pom.xml').write_text( + '4.0.0' + 'org.fireflyframework' + 'fireflyframework-parent' + escape(version) + + '' + escape(os.path.relpath(parent, root)) + '' + 'org.fireflyframework.qualification' + 'grpc-alignment-probe1' + '' + dependencies + '') + command = [args.maven, '-B', '-ntp'] + if args.settings: + command.extend(['-s', str(args.settings.resolve())]) + if args.repository: + command.append('-Dmaven.repo.local=' + str(args.repository.resolve())) + command.extend(['help:effective-pom', '-Doutput=' + str(root / 'effective.xml'), + 'dependency:tree', '-Dincludes=io.grpc', + '-DoutputFile=' + str(root / 'tree.txt')]) + process = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=180) + if process.returncode: + raise SystemExit(process.stdout + process.stderr) + effective = ET.parse(root / 'effective.xml') + expected = effective.findtext('m:properties/m:grpc.version', namespaces=namespace) + tree = (root / 'tree.txt').read_text() + resolved = sorted(set(re.findall(r'io\.grpc:([^:\s]+):jar:([^:\s]+):', tree))) + mismatches = [{'artifact': artifact, 'version': selected} + for artifact, selected in resolved if selected != expected] + report = {'status': 'PASS' if resolved and not mismatches else 'FAIL', + 'framework_version': version, 'grpc_generator_version': expected, + 'resolved': [dict(artifact=a, version=v) for a, v in resolved], + 'mismatches': mismatches, 'dependency_tree': tree, + 'scope': 'Actual Maven consumer graph; native compiler and transport need runtime qualification.'} + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + return report['status'] != 'PASS' + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/release/test_github_packages.py b/tests/release/test_github_packages.py index f4cebbfff..216541931 100644 --- a/tests/release/test_github_packages.py +++ b/tests/release/test_github_packages.py @@ -24,7 +24,10 @@ spec = importlib.util.spec_from_file_location('release_tools', SOURCE / 'scripts/release_tools.py') r = importlib.util.module_from_spec(spec) spec.loader.exec_module(r) -PROJECTS = r.check_reactor(SOURCE, expected=86) +CURRENT_PROJECTS = r.check_reactor(SOURCE, expected=87) +NATIVE_COORDINATE = ('org.fireflyframework', 'fireflyframework-batch') +PROJECTS = [project for project in CURRENT_PROJECTS + if (project['groupId'], project['artifactId']) != NATIVE_COORDINATE] ASSOCIATIONS = [json.loads(line) for line in (SOURCE / 'scripts/package-associations.jsonl').read_text().splitlines()] @@ -97,6 +100,33 @@ def write_registry(self, projects, inventory, snapshot): metadata = f'''{project['groupId']}{project['artifactId']}{project['version']}20261018.1234567{''.join(versions)}''' (self.root / home['repository'] / base / 'maven-metadata.xml').write_text(metadata) + def test_current_reactor_contains_exactly_one_new_source_owned_coordinate(self): + current = {(p['groupId'], p['artifactId']) for p in CURRENT_PROJECTS} + historical = {(p['groupId'], p['artifactId']) for p in PROJECTS} + self.assertEqual(87, len(CURRENT_PROJECTS)) + self.assertEqual(86, len(PROJECTS)) + self.assertEqual({NATIVE_COORDINATE}, current - historical) + self.assertEqual(historical | {NATIVE_COORDINATE}, current) + self.assertEqual(316, len(r.required_paths(CURRENT_PROJECTS))) + self.assertEqual(4, len(r.required_paths(CURRENT_PROJECTS) - r.required_paths(PROJECTS))) + + def test_release_cli_default_includes_source_owned_native_module(self): + output = self.root / 'current-reactor.json' + result = subprocess.run(['python3', str(SOURCE / 'scripts/release_tools.py'), 'check', + '--root', str(SOURCE), '--output', str(output)], + capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(87, len(json.loads(output.read_text()))) + + def test_current_full_reactor_plan_fails_closed_for_unmapped_native_coordinate(self): + output = self.root / 'unmapped-current-plan' + argv = ['github_packages.py', 'plan', '--root', str(SOURCE), + '--repository', str(self.root / 'no-staging-repository'), '--output', str(output)] + with patch.object(m.sys, 'argv', argv): + with self.assertRaisesRegex(ValueError, 'missing existing package home: org.fireflyframework.fireflyframework-batch'): + m.main() + self.assertFalse(output.exists()) + def test_all84_bootstrap_homes_retain_captured_association_digest(self): self.assertEqual(len(self.homes['artifacts']), 84) self.assertEqual(m.validate_homes(PROJECTS, self.homes), self.homes['artifacts']) @@ -281,7 +311,7 @@ def test_plan_rejects_association_changes_before_staging(self): output = self.root / 'output' argv = ['github_packages.py', 'plan', '--root', str(SOURCE), '--associations', str(associations), '--repository', str(self.root / 'missing-repository'), '--output', str(output)] - with patch.object(m.sys, 'argv', argv): + with patch.object(m.sys, 'argv', argv), patch.object(m, 'release_tools', return_value=r), patch.object(r, 'check_reactor', return_value=PROJECTS): with self.assertRaisesRegex(ValueError, 'canonical'): m.main() self.assertFalse(output.exists()) diff --git a/tests/test_migration_contract.py b/tests/test_migration_contract.py index 24e9be1f2..a2048a52c 100644 --- a/tests/test_migration_contract.py +++ b/tests/test_migration_contract.py @@ -6,6 +6,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] NS = {'m': 'http://maven.apache.org/POM/4.0.0'} VERSION = ET.parse(ROOT / 'pom.xml').getroot().findtext('m:version', namespaces=NS) +NATIVE_MODULES = {'modules/batch'} def read(path): @@ -33,7 +34,31 @@ def test_every_source_is_in_the_root_reactor(self): root = read(ROOT / 'pom.xml') modules = {x.text for x in root.findall('m:modules/m:module', NS)} sources = json.loads((ROOT / 'docs/migration/source-manifest.json').read_text())['sources'] - self.assertEqual({x['destination'] for x in sources}, modules) + self.assertEqual({x['destination'] for x in sources} | NATIVE_MODULES, modules) + + def test_native_batch_module_preserves_historical_source_classification(self): + historical = {x['destination'] for x in json.loads( + (ROOT / 'docs/migration/source-manifest.json').read_text())['sources']} + self.assertEqual({'modules/batch'}, NATIVE_MODULES) + self.assertTrue(NATIVE_MODULES.isdisjoint(historical)) + batch = read(ROOT / 'modules/batch/pom.xml') + self.assertEqual('fireflyframework-batch', value(batch, 'artifactId')) + self.assertEqual('fireflyframework-parent', value(batch, 'parent/m:artifactId')) + production = [d for d in batch.findall('m:dependencies/m:dependency', NS) + if value(d, 'scope') != 'test'] + self.assertEqual({('org.springframework.batch', 'spring-batch-core'), + ('org.springframework', 'spring-jdbc'), + ('org.springframework.boot', 'spring-boot-autoconfigure')}, + {(value(d, 'groupId'), value(d, 'artifactId')) for d in production}) + self.assertTrue(all(value(d, 'version') is None for d in production)) + + def test_native_batch_module_is_not_implicitly_adopted_by_other_modules(self): + for path in (ROOT / 'modules').rglob('pom.xml'): + if 'target' in path.parts or path == ROOT / 'modules/batch/pom.xml': + continue + dependencies = read(path).findall('m:dependencies/m:dependency', NS) + with self.subTest(path=path.relative_to(ROOT)): + self.assertNotIn('fireflyframework-batch', [value(d, 'artifactId') for d in dependencies]) def test_framework_parents_resolve_from_source(self): for path in list((ROOT / 'modules').rglob('pom.xml')): From 45533ab57470a3e7c0ecaaa16e3fa53e0a3ec9a9 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 13:45:55 -0700 Subject: [PATCH 02/44] Index durable batch under business coordination --- docs/site/assets/capabilities.json | 5 +- docs/site/assets/module-catalog.json | 8 ++ docs/site/assets/modules/ORIGIN.json | 1 + docs/site/assets/modules/batch.svg | 1 + .../capabilities/business-coordination.md | 9 +- docs/site/modules/batch.md | 97 +++++++++++++++++++ docs/site/modules/index.md | 1 + mkdocs.yml | 1 + modules/batch/README.md | 4 + scripts/generate_docs_nav.py | 8 +- tests/test_docs_navigation.py | 12 +++ 11 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 docs/site/assets/modules/batch.svg create mode 100644 docs/site/modules/batch.md diff --git a/docs/site/assets/capabilities.json b/docs/site/assets/capabilities.json index 948768995..9141bc7cd 100644 --- a/docs/site/assets/capabilities.json +++ b/docs/site/assets/capabilities.json @@ -106,9 +106,10 @@ { "id": "business-coordination", "title": "Business coordination", - "summary": "Coordinate workflows, compensating sagas, and try-confirm-cancel steps with explicit recovery and failure policies.", + "summary": "Coordinate workflows, compensating sagas, try-confirm-cancel steps, and restartable JDBC jobs with explicit recovery and failure policies.", "modules": [ - "orchestration" + "orchestration", + "batch" ] }, { diff --git a/docs/site/assets/module-catalog.json b/docs/site/assets/module-catalog.json index b3ce48ac4..0d2a568f9 100644 --- a/docs/site/assets/module-catalog.json +++ b/docs/site/assets/module-catalog.json @@ -63,6 +63,14 @@ "packaging": "jar", "bom_managed": true }, + { + "slug": "batch", + "title": "Durable Batch", + "artifact": "fireflyframework-batch", + "path": "modules/batch", + "packaging": "jar", + "bom_managed": true + }, { "slug": "cache", "title": "Common Cache Library", diff --git a/docs/site/assets/modules/ORIGIN.json b/docs/site/assets/modules/ORIGIN.json index 8ce9ee470..6394616a5 100644 --- a/docs/site/assets/modules/ORIGIN.json +++ b/docs/site/assets/modules/ORIGIN.json @@ -10,6 +10,7 @@ "agentic-bridge-starter.svg": "a612f38347a9ab50879b4e6737236bde3212c44bf78eadd2f6da715a67546d40", "agentic-bridge.svg": "e741603c19aad81eab2c28091477e055c762e55296e040a51e12ea6c2310bc62", "backoffice.svg": "306a3c6b5847b602b4be6d96703fb6934cd17f5ada9d0d0e625447509146e1c6", + "batch.svg": "b25dafd5978c184e3a6311dea36d9b2d6a06bce2e34d9b8ced08ee64b9b1fcb3", "cache.svg": "714d5fb6441929886ca983441047241cda2b567c44a8336644c23c528978c853", "cache-hazelcast.svg": "a848c432045159a4de9b8e86e32b1ef8e050bf68e3c165dac7f6c7c7c3a8196b", "cache-jcache.svg": "b61e3697bf9539df81445c6dbbc265fbbcea2e570f7fec0387ba02fd151eded0", diff --git a/docs/site/assets/modules/batch.svg b/docs/site/assets/modules/batch.svg new file mode 100644 index 000000000..274fb3fa6 --- /dev/null +++ b/docs/site/assets/modules/batch.svg @@ -0,0 +1 @@ +Durable Batch: jaflyjafly - Java framework by Firefly \ No newline at end of file diff --git a/docs/site/capabilities/business-coordination.md b/docs/site/capabilities/business-coordination.md index ac5787e5f..cf74fa84d 100644 --- a/docs/site/capabilities/business-coordination.md +++ b/docs/site/capabilities/business-coordination.md @@ -11,8 +11,9 @@ Customer onboarding can involve recording an application, checking eligibility, | Completed steps need a business undo action if later work fails | Saga with explicit compensations | | Resources can be reserved before a final decision | Try-confirm-cancel participants | | Work has dependencies and an execution state to inspect | Workflow with ordered steps | +| A bounded input snapshot needs stored checkpoints and restart after process failure | Durable JDBC batch | -All three belong to the [orchestration module](../modules/orchestration.md). A local command handler is still the smallest choice when the whole operation fits one service decision. +Saga, TCC, and workflow belong to the [orchestration module](../modules/orchestration.md). [Durable batch](../modules/batch.md) uses native Spring Batch JDBC metadata and explicit local transactions for restartable jobs. A local command handler is still the smallest choice when the whole operation fits one service decision. ## Follow the onboarding examples @@ -32,6 +33,12 @@ Set timeouts and retries to fit each operation. Keep an execution ID so operator The example demonstrates engine behavior with controlled providers. Before replacing them, test the real provider's duplicate calls, reservation expiry, compensation failure, and recovery after a process restart. Do not infer cross-service transactions or exactly-once work from an engine success result. +## Restart bounded jobs from stored checkpoints + +For an application-owned reconciliation or export job, retain the exact input snapshot, job parameters and item cursor before restarting. The optional batch module requires an explicitly selected PostgreSQL DataSource, matching JDBC transaction manager, bounded executor, and deployment-owned metadata schema. It does not launch jobs or create a scheduler automatically. + +A writer participates in the local transaction only through the same selected DataSource. Remote SDK calls, a second database and R2DBC work remain outside that JDBC transaction. Keep remote authorization and producer I/O outside held database locks, and reconcile retained business receipts before treating native COMPLETED as a business result. See the [Batch guide](../modules/batch.md) for configuration and the maintained restart/process-lifecycle fixtures. + ## Keep coordination and history separate The [event-store tutorial](../guides/onboarding-event-store.md) proves a PostgreSQL append/replay boundary for the application aggregate. It does not atomically combine that database with a saga's remote calls. Decide where application state is committed and how incomplete coordination will be reconciled. diff --git a/docs/site/modules/batch.md b/docs/site/modules/batch.md new file mode 100644 index 000000000..8d68bd8d1 --- /dev/null +++ b/docs/site/modules/batch.md @@ -0,0 +1,97 @@ +![Jafly durable batch](../assets/modules/batch.svg) + +# Durable batch + +Run application-owned restartable jobs with native Spring Batch JDBC metadata, explicit transactions, and PostgreSQL checkpoints. Use it when a bounded input snapshot must resume from committed progress after a process stops or fails. + +| Maven artifact | Packaging | Source project | +| --- | --- | --- | +| `org.fireflyframework:fireflyframework-batch` | `jar` | [modules/batch](https://github.com/fireflyframework/fireflyframework-jafly/tree/main/modules/batch) | + +Part of [Business coordination](index.md#business-coordination), alongside [orchestration](orchestration.md). A batch checkpoint records local job progress; a saga or TCC definition supplies application-specific coordination and failure behavior. + +## Add the module explicitly + +Check [installation and version availability](../installation.md) before choosing a published artifact. The current source candidate uses: + +```xml + + org.fireflyframework + fireflyframework-batch + 26.10.22 + +``` + +The parent/BOM manage this optional coordinate. Existing starters do not include it. The module does not create a DataSource, launch a job, schedule work, or apply database DDL. The host application supplies the PostgreSQL JDBC driver and owns its resources and migrations. + +## Select the database, transaction manager, and executor + +```yaml +spring: + batch: + job: + enabled: false +firefly: + batch: + durable: + enabled: true + data-source-ref: batchMetadataDataSource + transaction-manager-ref: batchMetadataTransactions + task-executor-ref: batchWorkers +``` + +The feature defaults to disabled. Enabled mode requires all three exact bean names and explicit `spring.batch.job.enabled=false`. A conventional bean name, `@Primary`, another database, or an R2DBC connection factory does not select a fallback. + +| Application bean | Required contract | +| --- | --- | +| `batchMetadataDataSource` | PostgreSQL JDBC `DataSource` selecting the intended metadata schema | +| `batchMetadataTransactions` | `DataSourceTransactionManager`, or its `JdbcTransactionManager` subtype, using that same DataSource object | +| `batchWorkers` | Initialized `ThreadPoolTaskExecutor`, equal core/max sizes from 1 through 32, queue capacity 0, and `ThreadPoolExecutor.AbortPolicy` | + +The application owns executor shutdown. Keep JDBC job work off a reactive HTTP event loop. The [module README](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/batch/README.md#select-three-resources-and-disable-startup-jobs) contains the complete resource configuration fragment with environment-bound connection properties. + +Existing application-provided Batch infrastructure remains application-owned. The module's configuration backs off when native project providers are supplied; it does not replace a project's repository or operator behind its back. + +## Provision the native metadata schema + +Have the deployment owner apply [the bundled PostgreSQL 6.0.5 schema](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/batch/src/main/resources/org/fireflyframework/batch/jdbc/schema-postgresql-6.0.5.sql) through the application's controlled migration process. Its SHA-256 is `f2ac75cae966e4eb1cd97ee6b11f130495b7109153754a7983befb87614d6e38`. It is outside Flyway's default migration location and has no automatic initializer. + +The initial supported table prefix is `BATCH_`. The runtime role needs schema usage, native metadata table read/insert/update, and sequence usage/read access. It does not need schema ownership or CREATE permission. Native administrative deletion APIs require additional deletion privileges if the deployment elects to expose them. + +Startup checks PostgreSQL identity, required columns/types, native sequences, and access without inserting a job or advancing a sequence. Missing schema or unsuitable resources fail configuration instead of creating a second metadata store. These checks do not certify storage durability, backups, or every migration constraint. + +## Bind step transactions explicitly + +The injected `JdbcBatchSteps` bean exposes the native builders: + +| Entry point | Behavior | +| --- | --- | +| `chunk(name, chunkSize)` | Returns a native `ChunkOrientedStepBuilder` with the selected JDBC transaction manager | +| `tasklet(name, tasklet)` | Returns a native tasklet builder with that same manager | + +Use normal Spring Batch readers, processors, writers, streams, listeners, and job builders. A JDBC writer participates in the local transaction only when it uses the selected DataSource through transaction-aware access such as `JdbcTemplate` or `DataSourceUtils`. + +A second database, independently opened connection, R2DBC operation, or remote SDK call is outside that local JDBC transaction. A durable metadata repository alone does not make item writes atomic: the native chunk builder otherwise defaults to a resource-free transaction manager. The [maintained checkpoint fixture](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcCheckpointDatabaseTest.java) demonstrates rollback and restart with real PostgreSQL. + +## Preserve inputs and inspect stored executions + +A restartable reader must save a stable cursor in its `ExecutionContext` and reopen from it. The cursor must refer to an immutable input snapshot with stable ordering and item identity. Re-querying a changed account population under yesterday's cursor does not establish a valid restart. + +Use the Batch 6 object-based operator APIs with the job registered under its stable name: + +- `JobOperator.start(Job, JobParameters)` starts the retained job definition and parameters. +- `stop(JobExecution)` requests a stop; the current chunk may still commit. Re-read the stored execution and checkpoint before restarting. +- `restart(JobExecution)` starts a new attempt for a restartable execution. +- `recover(JobExecution)` is an explicit recovery operation; retain the previous attempt and inspect the stored result through `JobRepository`. + +The [packaged process fixture](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java) covers stop, process death/recovery/restart, and concurrent admission. An operator's HTTP or CLI boundary still needs legitimate current authority and deployment-specific limits; the module does not expose one automatically. + +## Keep business receipts authoritative + +A reconciliation or settlement owner must retain its full business intent, input snapshot and producer references. Native `COMPLETED` records framework execution progress; it does not replace acknowledgments from the financial owner or establish that external effects occurred. + +The [owner-run fixture](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/OwnedRunBoundaryDatabaseTest.java) uses a test-owned synthetic principal and an in-process current-policy decision. It demonstrates replay checks and an owner OPEN state when retained acknowledgments are missing despite native COMPLETED. It does not qualify a real financial producer or identity provider. Keep remote policy and producer I/O outside held database locks and implement the application's durable effect protocol. + +## Qualification boundary + +The focused module source has 56 passing tests, including real PostgreSQL and separate packaged processes. A retained Batch-containing whole-framework gate has 3,827 registered tests with 24 skipped and no failures/errors. The later web-privacy source change has its own verification; the current combined whole reactor, independent Batch review, and package-home qualification remain separate pending gates. No published availability, product adoption, GL posting, or financial close follows from this documentation entry. diff --git a/docs/site/modules/index.md b/docs/site/modules/index.md index d9d56beee..2fc34c530 100644 --- a/docs/site/modules/index.md +++ b/docs/site/modules/index.md @@ -115,6 +115,7 @@ Coordinate eligibility checks, capacity reservations, and review steps. [Follow | Component | Purpose | Role | | --- | --- | --- | | [Orchestration](orchestration.md) | Coordinate workflows, compensating sagas, and try-confirm-cancel operations in a reactive application. | application library | +| [Durable Batch](batch.md) | Run application-owned restartable jobs with explicit JDBC transactions and PostgreSQL checkpoints. | optional persistence foundation | ## Service operations diff --git a/mkdocs.yml b/mkdocs.yml index fbf4f7e7d..fa381a8b1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -184,6 +184,7 @@ nav: - "ECM eSignature: Logalty": modules/ecm-esignature-logalty.md - "Business coordination": - "Orchestration": modules/orchestration.md + - "Durable batch": modules/batch.md - "Service operations": - "Observability": modules/observability.md - "Web": modules/web.md diff --git a/modules/batch/README.md b/modules/batch/README.md index 3ff6091de..1d0521654 100644 --- a/modules/batch/README.md +++ b/modules/batch/README.md @@ -1,5 +1,9 @@ +![Jafly durable batch](../../docs/site/assets/modules/batch.svg) + # Native Durable Batch +[Site guide](https://fireflyframework.github.io/fireflyframework-jafly/modules/batch/) · [Business coordination](https://fireflyframework.github.io/fireflyframework-jafly/capabilities/business-coordination/) + Optional Spring Batch JDBC infrastructure for application-owned restartable jobs. This module uses the framework's Boot-managed Spring Batch 6.0.5, a PostgreSQL JobRepository, explicit JDBC transactions, and a bounded application-owned executor. Module qualification has passed 56 tests, including actual PostgreSQL rollback/checkpoints and separate packaged JVM stop, hard-kill/recovery/restart, and concurrent launch. Whole-framework compatibility and independent review remain pending. No published availability or product adoption is claimed. diff --git a/scripts/generate_docs_nav.py b/scripts/generate_docs_nav.py index dc99c62f0..d417c16be 100644 --- a/scripts/generate_docs_nav.py +++ b/scripts/generate_docs_nav.py @@ -12,7 +12,7 @@ ('external-services', 'External services', 'Connect HTTP, SOAP, gRPC, notifications, callbacks, and Agentic through explicit service and provider integrations.'), ('event-driven-integration', 'Event-driven integration', 'Use event contracts and transport adapters for in-process events, Kafka, RabbitMQ, PostgreSQL, and inbound webhooks.'), ('domain-and-data', 'Domain and data', 'Choose reactive data helpers, optional event history, cache providers, content storage, and signature adapters.'), - ('business-coordination', 'Business coordination', 'Coordinate workflows, compensating sagas, and try-confirm-cancel steps with explicit recovery and failure policies.'), + ('business-coordination', 'Business coordination', 'Coordinate workflows, compensating sagas, try-confirm-cancel steps, and restartable JDBC jobs with explicit recovery and failure policies.'), ('service-operations', 'Service operations', 'Connect application telemetry, HTTP conventions, health checks, and centralized configuration.'), ('team-wide-architecture', 'Team-wide architecture', 'Share a dependency train, build conventions, service-layer starters, common types, validation, and extension contracts.'), ] @@ -29,7 +29,7 @@ def category(slug): return 3 if slug.startswith(('cache', 'ecm')) or slug in ('r2dbc', 'eventsourcing'): return 4 - if slug == 'orchestration': + if slug in ('orchestration', 'batch'): return 5 if slug in ('web', 'observability', 'config-server'): return 6 @@ -44,7 +44,7 @@ def category(slug): 'client', 'client-complete-example', 'notifications', 'notifications-core', 'callbacks', 'agentic-bridge', 'agentic-bridge-core', 'agentic-bridge-autoconfigure', 'agentic-bridge-starter', 'eda', 'eda-kafka', 'eda-rabbitmq', 'eda-postgres', 'webhooks', - 'r2dbc', 'eventsourcing', 'cache', 'ecm', 'orchestration', 'observability', 'web', 'config-server', + 'r2dbc', 'eventsourcing', 'cache', 'ecm', 'orchestration', 'batch', 'observability', 'web', 'config-server', 'parent', 'bom', 'kernel', 'utils', 'validators', 'starter-core', 'starter-domain', 'starter-application', 'starter-data', 'backoffice', 'plugins', 'plugin-api', 'plugin-core', ] @@ -78,6 +78,8 @@ def generated(root): continue label = sentence_case(item['title'].removeprefix('Common ').removeprefix(': ').removesuffix(' Library')) lines.append(' - ' + json.dumps(label) + ': modules/' + item['slug'] + '.md') + if item['slug'] == 'web': + lines.append(' - "HTTP idempotency": modules/web-idempotency.md') return '\n'.join(lines) + '\n', json.dumps(capabilities, indent=2) + '\n' diff --git a/tests/test_docs_navigation.py b/tests/test_docs_navigation.py index 10eadfade..71b440faf 100644 --- a/tests/test_docs_navigation.py +++ b/tests/test_docs_navigation.py @@ -39,6 +39,18 @@ def test_every_catalog_module_keeps_exactly_one_sidebar_link(self): for item in catalog: self.assertEqual(rendered.count(f"modules/{item['slug']}.md"), 1, item['slug']) + def test_durable_batch_is_grouped_with_orchestration(self): + self.assertEqual(nav.category('batch'), nav.category('orchestration')) + rendered, capabilities = nav.generated(ROOT) + coordination = next(item for item in json.loads(capabilities) + if item['id'] == 'business-coordination') + self.assertIn('batch', coordination['modules']) + self.assertEqual(rendered.count('modules/batch.md'), 1) + + def test_existing_http_idempotency_guide_keeps_its_sidebar_link(self): + rendered, _ = nav.generated(ROOT) + self.assertEqual(rendered.count('modules/web-idempotency.md'), 1) + def test_missing_learning_guide_fails_instead_of_hiding_the_route(self): (self.root / 'docs/site/guides/onboarding-coordination.md').unlink() with self.assertRaisesRegex(ValueError, 'onboarding-coordination'): From 0a772adedb58878287b5d67ce3d3ce6398e7e9b9 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 13:51:30 -0700 Subject: [PATCH 03/44] Verify asynchronous callbacks by thread identity across scheduler modes --- .../unit/core/LifecycleCallbackAdvancedTest.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/unit/core/LifecycleCallbackAdvancedTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/unit/core/LifecycleCallbackAdvancedTest.java index 5a9ebe853..cb2281c24 100644 --- a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/unit/core/LifecycleCallbackAdvancedTest.java +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/unit/core/LifecycleCallbackAdvancedTest.java @@ -69,13 +69,14 @@ public void asyncComplete() { void sagaAsyncCallback_invokesOnDifferentThread() throws Exception { AsyncSagaBean.latch.countDown(); // reset var latch = new CountDownLatch(1); - var threadName = new AtomicReference(); + var callingThread = Thread.currentThread(); + var callbackThread = new AtomicReference(); @SuppressWarnings("unused") class AsyncBean { @OnSagaComplete(async = true) public void asyncComplete() { - threadName.set(Thread.currentThread().getName()); + callbackThread.set(Thread.currentThread()); latch.countDown(); } } @@ -98,7 +99,7 @@ public void asyncComplete() { // Wait for async callback assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(threadName.get()).contains("boundedElastic"); + assertThat(callbackThread.get()).isNotNull().isNotSameAs(callingThread); } // ── Saga Priority ──────────────────────────────────────────── @@ -144,13 +145,14 @@ class PrioritySagaBean { @Test void tccAsyncCallback_invokesOnDifferentThread() throws Exception { var latch = new CountDownLatch(1); - var threadName = new AtomicReference(); + var callingThread = Thread.currentThread(); + var callbackThread = new AtomicReference(); @SuppressWarnings("unused") class AsyncTccBean { @OnTccComplete(async = true) public void asyncComplete() { - threadName.set(Thread.currentThread().getName()); + callbackThread.set(Thread.currentThread()); latch.countDown(); } } @@ -178,7 +180,7 @@ public void asyncComplete() { .verifyComplete(); assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(threadName.get()).contains("boundedElastic"); + assertThat(callbackThread.get()).isNotNull().isNotSameAs(callingThread); } // ── TCC Priority ──────────────────────────────────────────── From 48fcca16ab9f57cd55f072ebb201e0f0f503878f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 14:17:07 -0700 Subject: [PATCH 04/44] Validate initialized Batch executors before admitting durable work --- .../autoconfigure/JdbcBatchResources.java | 16 ++- .../JdbcBatchResourceSelectionTest.java | 112 ++++++++++++++++ .../jdbc/JdbcBatchProcessDatabaseIT.java | 124 +++++++++++++++--- 3 files changed, 234 insertions(+), 18 deletions(-) diff --git a/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResources.java b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResources.java index e4199894c..4fdf67fb4 100644 --- a/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResources.java +++ b/modules/batch/src/main/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResources.java @@ -20,6 +20,7 @@ import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import javax.sql.DataSource; @@ -40,7 +41,7 @@ static JdbcBatchResources resolve(DurableBatchProperties properties, BeanFactory if (manager.getDataSource() != source) { throw new IllegalStateException( "Durable Batch manager must own the same DataSource selected by" - + " data-source-ref"); + + " data-source-ref"); } ThreadPoolTaskExecutor executor = selected( @@ -58,7 +59,18 @@ static JdbcBatchResources resolve(DurableBatchProperties properties, BeanFactory throw new IllegalStateException( "Durable Batch task-executor-ref requires queueCapacity=0"); } - if (!(executor.getThreadPoolExecutor().getRejectedExecutionHandler() + ThreadPoolExecutor initialized = executor.getThreadPoolExecutor(); + if (initialized.getCorePoolSize() < 1 + || initialized.getCorePoolSize() > 32 + || initialized.getCorePoolSize() != initialized.getMaximumPoolSize()) { + throw new IllegalStateException( + "Durable Batch initialized executor requires core=max in 1..32"); + } + if (!(initialized.getQueue() instanceof SynchronousQueue)) { + throw new IllegalStateException( + "Durable Batch initialized executor requires a SynchronousQueue"); + } + if (!(initialized.getRejectedExecutionHandler() instanceof ThreadPoolExecutor.AbortPolicy)) { throw new IllegalStateException("Durable Batch task-executor-ref requires AbortPolicy"); } diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResourceSelectionTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResourceSelectionTest.java index af3324dab..93af8ed03 100644 --- a/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResourceSelectionTest.java +++ b/modules/batch/src/test/java/org/fireflyframework/batch/autoconfigure/JdbcBatchResourceSelectionTest.java @@ -16,11 +16,13 @@ package org.fireflyframework.batch.autoconfigure; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import io.r2dbc.spi.ConnectionFactory; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.core.task.SyncTaskExecutor; @@ -30,6 +32,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.sql.Connection; +import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; @@ -158,4 +161,113 @@ void callerRunsRejectionIsNotAllowed() { resources(dataSource, new DataSourceTransactionManager(dataSource), executor), "AbortPolicy"); } + + private JdbcBatchResources selectedInitializedExecutor(ThreadPoolTaskExecutor executor) { + var beans = new DefaultListableBeanFactory(); + beans.registerSingleton("ownedDataSource", dataSource); + beans.registerSingleton("ownedManager", new DataSourceTransactionManager(dataSource)); + beans.registerSingleton("ownedExecutor", executor); + var properties = new DurableBatchProperties(); + properties.setDataSourceRef("ownedDataSource"); + properties.setTransactionManagerRef("ownedManager"); + properties.setTaskExecutorRef("ownedExecutor"); + return JdbcBatchResources.resolve(properties, beans); + } + + private void rejectsInitializedExecutor(ThreadPoolTaskExecutor executor, String reason) { + try { + assertThatThrownBy(() -> selectedInitializedExecutor(executor)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(reason); + assertThat(connections).hasValue(0); + } finally { + executor.shutdown(); + } + } + + @Test + void changedCapacityCannotHideInitializedUnboundedQueue() { + var executor = executor(); + executor.setQueueCapacity(Integer.MAX_VALUE); + executor.initialize(); + executor.setQueueCapacity(0); + assertThat(executor.getQueueCapacity()).isZero(); + assertThat(executor.getThreadPoolExecutor().getQueue().remainingCapacity()) + .isEqualTo(Integer.MAX_VALUE); + rejectsInitializedExecutor(executor, "SynchronousQueue"); + } + + @Test + void fullBoundedQueueCannotPassAsZeroCapacityHandoff() { + var executor = executor(); + executor.setQueueCapacity(1); + executor.initialize(); + executor.getThreadPoolExecutor().getQueue().add(() -> {}); + executor.setQueueCapacity(0); + assertThat(executor.getThreadPoolExecutor().getQueue().remainingCapacity()).isZero(); + rejectsInitializedExecutor(executor, "SynchronousQueue"); + } + + @Test + void backingMaximumCannotDifferFromFixedConfiguredLimit() { + var executor = executor(); + executor.initialize(); + executor.getThreadPoolExecutor().setMaximumPoolSize(Integer.MAX_VALUE); + assertThat(executor.getMaxPoolSize()).isEqualTo(2); + rejectsInitializedExecutor(executor, "core=max"); + } + + @Test + void backingCoreCannotBeZeroDespiteConfiguredLimit() { + var executor = executor(); + executor.initialize(); + executor.getThreadPoolExecutor().setCorePoolSize(0); + assertThat(executor.getCorePoolSize()).isEqualTo(2); + rejectsInitializedExecutor(executor, "core=max"); + } + + @Test + void backingFixedPoolCannotExceedThirtyTwoDespiteConfiguredLimit() { + var executor = executor(); + executor.initialize(); + executor.getThreadPoolExecutor().setMaximumPoolSize(33); + executor.getThreadPoolExecutor().setCorePoolSize(33); + assertThat(executor.getCorePoolSize()).isEqualTo(2); + assertThat(executor.getMaxPoolSize()).isEqualTo(2); + rejectsInitializedExecutor(executor, "core=max"); + } + + @Test + void backingCallerRunsPolicyIsRejected() { + var executor = executor(); + executor.initialize(); + executor.getThreadPoolExecutor() + .setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + rejectsInitializedExecutor(executor, "AbortPolicy"); + } + + @Test + void actualSingleWorkerSynchronousExecutorIsAccepted() { + acceptsFixedSynchronousExecutor(1); + } + + @Test + void actualThirtyTwoWorkerSynchronousExecutorIsAccepted() { + acceptsFixedSynchronousExecutor(32); + } + + private void acceptsFixedSynchronousExecutor(int size) { + var executor = executor(); + executor.setCorePoolSize(size); + executor.setMaxPoolSize(size); + executor.initialize(); + try { + assertThat(executor.getThreadPoolExecutor().getQueue()) + .isInstanceOf(SynchronousQueue.class); + assertThat(selectedInitializedExecutor(executor).taskExecutor()).isSameAs(executor); + assertThat(connections).hasValue(0); + } finally { + executor.shutdown(); + } + } } diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java index 0dc131cd6..8b1115b5d 100644 --- a/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java +++ b/modules/batch/src/test/java/org/fireflyframework/batch/jdbc/JdbcBatchProcessDatabaseIT.java @@ -71,7 +71,7 @@ private DataSource prepared() throws Exception { connection.setSchema(selected); statement.execute( "CREATE TABLE owned_items(run_id varchar(100) NOT NULL,item_no integer NOT" - + " NULL,PRIMARY KEY(run_id,item_no))"); + + " NULL,PRIMARY KEY(run_id,item_no))"); statement.execute("GRANT SELECT,INSERT ON owned_items TO " + role); } return source; @@ -210,6 +210,86 @@ private void success(Process process, String name) throws Exception { + Path.of(System.getProperty("durable.batch.jar")).toUri().toURL()); } + private static boolean isNativeEmptyExecutionObservation( + String output, long instanceId, long version) { + var lines = output.lines().toList(); + return lines.contains( + "Exception in thread \"main\" java.lang.IllegalStateException: Cannot find" + + " any job execution for job instance: JobInstance: id=" + + instanceId + + ", version=" + + version + + ", Job=[owned-checkpoint-job]") + && lines.contains( + "\tat org.springframework.batch.core.launch.support.TaskExecutorJobLauncher.createJobExecution(TaskExecutorJobLauncher.java:130)"); + } + + private static boolean isNativeSerializationRejection(String output) { + var lines = output.lines().toList(); + return lines.contains( + "Exception in thread \"main\"" + + " org.springframework.dao.CannotAcquireLockException:" + + " PreparedStatementCallback; SQL [INSERT INTO" + + " BATCH_JOB_INSTANCE(JOB_INSTANCE_ID, JOB_NAME, JOB_KEY, VERSION)") + && lines.contains( + "Caused by: org.postgresql.util.PSQLException: ERROR: could not serialize" + + " access due to read/write dependencies among transactions") + && lines.contains( + "\tat org.springframework.batch.core.repository.dao.jdbc.JdbcJobInstanceDao.createJobInstance(JdbcJobInstanceDao.java:164)") + && lines.contains( + "\tat org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:144)"); + } + + @Test + void nativeRejectionRequiresExactDiagnosticDescriptors() { + String observed = + "OWNED_READY_TO_START=true\n" + + "Exception in thread \"main\" java.lang.IllegalStateException: Cannot find" + + " any job execution for job instance: JobInstance: id=7, version=0," + + " Job=[owned-checkpoint-job]\n" + + "\tat org.springframework.batch.core.launch.support.TaskExecutorJobLauncher.createJobExecution(TaskExecutorJobLauncher.java:130)\n"; + assertThat(isNativeEmptyExecutionObservation(observed, 7, 0)).isTrue(); + for (String other : + List.of( + observed.replace("id=7", "id=8"), + observed.replace("version=0", "version=1"), + observed.replace("owned-checkpoint-job", "another-job"), + observed.replace("IllegalStateException", "IllegalArgumentException"), + observed.replace(":130)", ":131)"), + observed.replace("createJobExecution(", "otherMethod("), + observed.replace("Exception in thread", "Prefix Exception in thread"), + "java.lang.IllegalStateException: unrelated initialization failure")) { + assertThat(isNativeEmptyExecutionObservation(other, 7, 0)).isFalse(); + } + String serialization = + "Exception in thread \"main\" org.springframework.dao.CannotAcquireLockException:" + + " PreparedStatementCallback; SQL [INSERT INTO" + + " BATCH_JOB_INSTANCE(JOB_INSTANCE_ID, JOB_NAME, JOB_KEY, VERSION)\n" + + "\tat org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:144)\n" + + "\tat org.springframework.batch.core.repository.dao.jdbc.JdbcJobInstanceDao.createJobInstance(JdbcJobInstanceDao.java:164)\n" + + "Caused by: org.postgresql.util.PSQLException: ERROR: could not serialize" + + " access due to read/write dependencies among transactions\n"; + assertThat(isNativeSerializationRejection(serialization)).isTrue(); + for (String other : + List.of( + serialization.replace("BATCH_JOB_INSTANCE(", "ANOTHER_TABLE("), + serialization.replace( + "CannotAcquireLockException", "IllegalStateException"), + serialization.replace( + "could not serialize access due to read/write dependencies among" + + " transactions", + "deadlock detected"), + serialization.replace( + "org.postgresql.util.PSQLException", "java.sql.SQLException"), + serialization.replace(":164)", ":165)"), + serialization.replace(":144)", ":145)"), + serialization.replace("Exception in thread", "Prefix Exception in thread"), + "org.springframework.dao.CannotAcquireLockException: unrelated lock" + + " failure")) { + assertThat(isNativeSerializationRejection(other)).isFalse(); + } + } + @Test void simultaneousIndependentProcessesAdmitOneLiveExecutionAndOneEffectStream() throws Exception { @@ -244,22 +324,34 @@ void simultaneousIndependentProcessesAdmitOneLiveExecutionAndOneEffectStream() held(source, winner, winnerName); assertThat(loser.waitFor(10, TimeUnit.SECONDS)).isTrue(); assertThat(loser.exitValue()).isNotZero(); - assertThat(Files.readString(temporary.resolve(loserName + ".log"))) - .containsAnyOf( - "JobExecutionAlreadyRunningException", - "DuplicateKeyException", - "CannotSerializeTransactionException", - "JobInstanceAlreadyExistsException", - "java.lang.IllegalStateException: JobInstance must not already exist"); - assertThat( - new JdbcTemplate(source) - .queryForObject( - "SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + var jdbc = new JdbcTemplate(source); + assertThat(jdbc.queryForObject("SELECT count(*) FROM BATCH_JOB_INSTANCE", Integer.class)) + .isEqualTo(1); + var instance = + jdbc.queryForMap("SELECT JOB_INSTANCE_ID,VERSION,JOB_NAME FROM BATCH_JOB_INSTANCE"); + long instanceId = ((Number) instance.get("job_instance_id")).longValue(); + long instanceVersion = ((Number) instance.get("version")).longValue(); + assertThat(instance.get("job_name")).isEqualTo("owned-checkpoint-job"); + assertThat(jdbc.queryForObject("SELECT count(*) FROM BATCH_JOB_EXECUTION", Integer.class)) .isEqualTo(1); + var execution = jdbc.queryForMap("SELECT JOB_INSTANCE_ID,STATUS FROM BATCH_JOB_EXECUTION"); + assertThat(((Number) execution.get("job_instance_id")).longValue()).isEqualTo(instanceId); + assertThat(execution.get("status")).isEqualTo("STARTED"); + String loserOutput = Files.readString(temporary.resolve(loserName + ".log")); + assertThat(loserOutput).doesNotContain("OWNED_EXECUTION_ID=", "OWNED_FINAL_STATUS="); + if (!isNativeEmptyExecutionObservation(loserOutput, instanceId, instanceVersion) + && !isNativeSerializationRejection(loserOutput)) { + assertThat(loserOutput) + .containsAnyOf( + "JobExecutionAlreadyRunningException", + "DuplicateKeyException", + "CannotSerializeTransactionException", + "JobInstanceAlreadyExistsException", + "java.lang.IllegalStateException: JobInstance must not already exist"); + } Files.writeString(release, "owned admitted process may finish"); success(winner, winnerName); assertThat(rows(source)).containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); - var jdbc = new JdbcTemplate(source); assertThat( jdbc.queryForObject( "SELECT count(*) FROM BATCH_JOB_EXECUTION WHERE STATUS='COMPLETED'", @@ -268,7 +360,7 @@ void simultaneousIndependentProcessesAdmitOneLiveExecutionAndOneEffectStream() assertThat( jdbc.queryForObject( "SELECT count(*) FROM BATCH_JOB_EXECUTION WHERE STATUS IN" - + " ('STARTING','STARTED','STOPPING')", + + " ('STARTING','STARTED','STOPPING')", Integer.class)) .isZero(); Files.writeString( @@ -292,7 +384,7 @@ void killedProcessRequiresObservedDeathThenNativeRecoverAndRestartFromPersistedC new JdbcTemplate(source) .queryForObject( "SELECT STATUS FROM BATCH_JOB_EXECUTION WHERE" - + " JOB_EXECUTION_ID=?", + + " JOB_EXECUTION_ID=?", String.class, originalId)) .isEqualTo("STARTED"); @@ -340,7 +432,7 @@ void separateProcessRequestsStopAndFreshProcessRestartsSameInstance() throws Exc new JdbcTemplate(source) .queryForObject( "SELECT STATUS FROM BATCH_JOB_EXECUTION WHERE" - + " JOB_EXECUTION_ID=?", + + " JOB_EXECUTION_ID=?", String.class, originalId)) .isEqualTo("STOPPED"); From f0d75c71b83112b32e5be085d16b550d90307562 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 14:47:34 -0700 Subject: [PATCH 05/44] Require header bearer authority and retain native CSRF protection --- docs/site/modules/security-resource-server.md | 6 + modules/security-resource-server/README.md | 4 + .../docs/bearer-api-and-csrf.md | 32 ++ .../rs/ResourceServerAutoConfiguration.java | 3 +- .../BearerApiCsrfBoundaryIntegrationTest.java | 402 ++++++++++++++++++ 5 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 modules/security-resource-server/docs/bearer-api-and-csrf.md create mode 100644 modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/BearerApiCsrfBoundaryIntegrationTest.java diff --git a/docs/site/modules/security-resource-server.md b/docs/site/modules/security-resource-server.md index 0d6878442..e535ab4c7 100644 --- a/docs/site/modules/security-resource-server.md +++ b/docs/site/modules/security-resource-server.md @@ -81,6 +81,12 @@ PolicyRule denyReports() { } ``` +## Header bearer authentication and CSRF + +The default API chain authenticates protected routes through an explicit `Authorization: Bearer ` header and never restores authentication from a session cookie. Basic, form, query-token and token-cookie credentials are not alternate authorities. Native Spring Security CSRF protection remains enabled with its bearer exemption: legitimate signed bearer POST/PUT/PATCH/DELETE requests do not need CSRF tokens, while unsafe non-bearer requests do. A missing CSRF token can return 403 before authentication, including on explicitly allowlisted browser routes. A valid CSRF token does not authenticate a request. + +Applications that need session login supply their own filter chain through the existing bean-backoff seam and retain appropriate CSRF controls. CSRF state may use WebSession without making that session an API authentication authority. The [canonical header bearer and browser CSRF contract](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/security-resource-server/docs/bearer-api-and-csrf.md) documents exact transport/status compatibility, permitted browser token delivery, and the maintained real-HTTP boundaries. + ## Require an explicit current policy `firefly.security.resource-server.strict-policy: true` makes the fallback embedded policy deny when no rules are configured. A failed or empty rule publisher, or a decision without an effect, denies even when another rule permits. Normal `INDETERMINATE` results may abstain, but at least one explicit permit is required and any denial wins. The embedded provider retains permit obligations; an enforcing business boundary must fulfill them or deny access. diff --git a/modules/security-resource-server/README.md b/modules/security-resource-server/README.md index 7e24fa5c0..b686e03f3 100644 --- a/modules/security-resource-server/README.md +++ b/modules/security-resource-server/README.md @@ -76,6 +76,10 @@ PolicyRule denyReports() { } ``` +## Header bearer and browser requests + +The default chain uses explicit header bearer authentication and never restores authentication from a session cookie. Native Spring Security CSRF protection remains enabled with its bearer exemption: valid signed bearer unsafe requests need no CSRF token; unsafe non-bearer requests require CSRF, including allowlisted browser routes. Missing CSRF can return 403 before authentication. See the [header bearer and CSRF contract](docs/bearer-api-and-csrf.md) for transport, status and application-owned browser-chain details. + ## Configuration The `firefly.security.resource-server` namespace is bound by [ResourceServerProperties.java](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java). Nested configuration is declared in the linked class. Defaults are the field initializers. diff --git a/modules/security-resource-server/docs/bearer-api-and-csrf.md b/modules/security-resource-server/docs/bearer-api-and-csrf.md new file mode 100644 index 000000000..f702829b7 --- /dev/null +++ b/modules/security-resource-server/docs/bearer-api-and-csrf.md @@ -0,0 +1,32 @@ +# Header bearer APIs and browser CSRF + +The default resource-server chain authenticates protected routes from an explicit `Authorization: Bearer ` header. It does not load or save authentication through a WebSession. HTTP Basic, form login, query/form access tokens and token cookies are not alternative authentication transports. Supplying a session cookie cannot supply API authority, including when another application component previously stored an authenticated Spring Security context in that session. + +JWT signature, lifetime, issuer/audience settings and current URL policy still apply. A valid header token must satisfy policy on each protected request. An invalid header token does not fall back to a stored session, and a valid token takes its identity from that token when a conflicting authenticated session cookie is present. + +## Native CSRF behavior + +The chain retains Spring Security's native CSRF filter and the resource server's native bearer exemption. An explicit header bearer request can call POST, PUT, PATCH or DELETE without a CSRF token; the JWT decoder and policy still decide whether that request is authorized. No custom CSRF matcher or universal CSRF-disable switch is installed. + +Unsafe requests without explicit bearer authentication require a valid native CSRF token, even on paths listed in `permit-matchers`. `permitAll` is an authorization decision and does not disable CSRF protection. A browser application must supply its own permitted view/token-delivery route and include the native CSRF token in the supported header/form field. This module adds no token-delivery or browser-login endpoint. Spring's default CSRF token repository may use WebSession to store CSRF state; that storage does not make a session an authentication authority. + +| Request | Default-chain result | +| --- | --- | +| Protected safe request, no valid bearer | 401 | +| Protected unsafe request, no bearer and missing/invalid CSRF token | 403 before authentication | +| Protected unsafe request, valid CSRF/session but no bearer | 401 | +| Valid header bearer, current policy permits | Controller response; CSRF token not required | +| Valid header bearer, current policy denies | 403 | +| Invalid header bearer plus an authenticated session | 401 | +| Allowlisted unsafe browser request, no bearer and missing CSRF token | 403 | +| Allowlisted unsafe browser request with valid CSRF | Controller response, subject to its application-owned behavior | + +The non-bearer unsafe 403 result is an intentional correction from the earlier chain, which disabled CSRF globally and could load session authentication. Clients should send their legitimate header bearer token rather than infer authority from cookies or change HTTP methods to bypass a protection. Public browser/webhook routes must review their own explicit authentication and CSRF contract; an allowlist is not a business permission model. + +## Application-owned browser chains + +Applications that require session login supply their own `SecurityWebFilterChain` through the existing bean-backoff seam and retain appropriate CSRF controls. The default API chain is not a session-login implementation. Supplying another chain also transfers responsibility for matching routes, authentication, CSRF, policy and headers to that application configuration. + +The maintained [real HTTP boundary tests](../src/test/java/org/fireflyframework/security/rs/BearerApiCsrfBoundaryIntegrationTest.java) use runtime-signed synthetic JWTs, a deliberately server-seeded test session, native CSRF tokens and current policy decisions. They qualify this default chain; they do not establish a deployed identity provider, browser exploit, customer consent or financial authority. + +Primary reference: [Spring Security WebFlux CSRF](https://docs.spring.io/spring-security/reference/reactive/exploits/csrf.html). The qualified dependency is Spring Security 7.1.1; later documentation may describe a newer release. diff --git a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java index 6dbf01ea8..7ca7f76bc 100644 --- a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java +++ b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java @@ -54,6 +54,7 @@ import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository; import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter; import reactor.core.publisher.Mono; @@ -186,7 +187,7 @@ public SecurityWebFilterChain fireflySecurityWebFilterChain( PolicyAuthorizationManager policyManager = new PolicyAuthorizationManager(urlPolicy); http - .csrf(ServerHttpSecurity.CsrfSpec::disable) + .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) .formLogin(ServerHttpSecurity.FormLoginSpec::disable) .logout(ServerHttpSecurity.LogoutSpec::disable) diff --git a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/BearerApiCsrfBoundaryIntegrationTest.java b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/BearerApiCsrfBoundaryIntegrationTest.java new file mode 100644 index 000000000..b7794cfd6 --- /dev/null +++ b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/BearerApiCsrfBoundaryIntegrationTest.java @@ -0,0 +1,402 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.security.rs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import org.fireflyframework.security.api.domain.Decision; +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.security.core.policy.PolicyRule; +import org.fireflyframework.security.spi.KeyManagementPort; +import org.fireflyframework.security.webflux.authentication.FireflyAuthenticationToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; +import org.springframework.security.web.server.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.interfaces.RSAPrivateKey; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +@SpringBootTest( + classes = BearerApiCsrfBoundaryIntegrationTest.Application.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "server.address=127.0.0.1", + "firefly.security.resource-server.strict-policy=true", + "firefly.security.resource-server.permit-matchers=/fixture/**", + "spring.main.banner-mode=off" + }) +class BearerApiCsrfBoundaryIntegrationTest { + private static final AtomicInteger EFFECTS = new AtomicInteger(); + private static final AtomicInteger PUBLIC_EFFECTS = new AtomicInteger(); + private static final AtomicBoolean DENY = new AtomicBoolean(); + private static final AtomicReference POLICY_SUBJECT = new AtomicReference<>(); + @LocalServerPort int port; + @Autowired KeyManagementPort keys; + + @BeforeEach + void reset() { + EFFECTS.set(0); + PUBLIC_EFFECTS.set(0); + DENY.set(false); + POLICY_SUBJECT.set(null); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class Application { + @Bean + PolicyRule currentPolicy() { + return (principal, action, resource, context) -> { + POLICY_SUBJECT.set(principal.subject()); + return Mono.just( + DENY.get() + ? Decision.deny("Synthetic policy withdrawn") + : Decision.permit()); + }; + } + + @Bean + Controller controller() { + return new Controller(); + } + } + + @RestController + static class Controller { + @GetMapping("/fixture/session") + Mono seedAuthenticatedSession(ServerWebExchange exchange) { + var principal = SecurityPrincipal.builder().subject("synthetic-session-user").build(); + return new WebSessionServerSecurityContextRepository() + .save( + exchange, + new SecurityContextImpl(new FireflyAuthenticationToken(principal))) + .thenReturn("Test-owned authenticated session"); + } + + @GetMapping("/fixture/csrf") + Mono csrf(ServerWebExchange exchange) { + Mono token = exchange.getAttribute(CsrfToken.class.getName()); + assertNotNull(token, "Native CSRF filter must expose the token"); + return token.map(CsrfToken::getToken); + } + + @GetMapping("/fixture/header-session") + Mono headerSession(ServerWebExchange exchange) { + return exchange.getSession() + .map( + session -> { + session.start(); + return Boolean.toString( + session.getAttributes() + .containsKey("SPRING_SECURITY_CONTEXT")); + }); + } + + @GetMapping("/api/read") + String read() { + return "Synthetic protected read"; + } + + @RequestMapping( + value = "/api/write", + method = { + RequestMethod.POST, + RequestMethod.PUT, + RequestMethod.PATCH, + RequestMethod.DELETE + }) + String write() { + EFFECTS.incrementAndGet(); + return "Synthetic protected effect"; + } + + @PostMapping("/fixture/browser-write") + String publicWrite() { + PUBLIC_EFFECTS.incrementAndGet(); + return "Synthetic public effect"; + } + } + + @Test + void signedBearerUnsafeMethodsWorkWithoutCsrfTokens() throws Exception { + String token = token(); + for (String method : List.of("POST", "PUT", "PATCH", "DELETE")) { + assertEquals( + 200, + send(method, "/api/write", "", "Authorization", "Bearer " + token) + .statusCode()); + } + assertEquals(4, EFFECTS.get()); + } + + @Test + void sessionCookieCannotAuthenticateSafeProtectedRead() throws Exception { + assertEquals( + 401, send("GET", "/api/read", "", "Cookie", authenticatedSession()).statusCode()); + assertEquals(0, EFFECTS.get()); + } + + @Test + void sessionAndValidCsrfCannotAuthenticateUnsafeWrite() throws Exception { + var csrf = csrf(authenticatedSession()); + assertEquals( + 401, + send( + "POST", + "/api/write", + "", + "Cookie", + csrf.cookie(), + "X-CSRF-TOKEN", + csrf.token()) + .statusCode()); + assertEquals(0, EFFECTS.get()); + } + + @Test + void unsafeNonBearerRequestsRequireNativeCsrf() throws Exception { + for (String method : List.of("POST", "PUT", "PATCH", "DELETE")) { + assertEquals(403, send(method, "/api/write", "").statusCode()); + } + assertEquals(0, EFFECTS.get()); + } + + @Test + void allowlistedBrowserUnsafeEndpointStillRequiresCsrf() throws Exception { + assertEquals(403, send("POST", "/fixture/browser-write", "").statusCode()); + assertEquals(0, PUBLIC_EFFECTS.get()); + var csrf = csrf(null); + assertEquals( + 200, + send( + "POST", + "/fixture/browser-write", + "", + "Cookie", + csrf.cookie(), + "X-CSRF-TOKEN", + csrf.token()) + .statusCode()); + assertEquals(1, PUBLIC_EFFECTS.get()); + } + + @Test + void otherTransportsCannotAuthenticateEvenWithValidCsrf() throws Exception { + var csrf = csrf(null); + String basic = + Base64.getEncoder() + .encodeToString( + (UUID.randomUUID() + ":" + UUID.randomUUID()) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertEquals( + 401, + send( + "POST", + "/api/write", + "", + "Cookie", + csrf.cookie(), + "X-CSRF-TOKEN", + csrf.token(), + "Authorization", + "Basic " + basic) + .statusCode()); + String token = token(); + assertEquals( + 401, + send( + "POST", + "/api/write?access_token=" + token, + "", + "Cookie", + csrf.cookie(), + "X-CSRF-TOKEN", + csrf.token()) + .statusCode()); + assertEquals( + 401, + send( + "POST", + "/api/write", + "access_token=" + token, + "Cookie", + csrf.cookie(), + "X-CSRF-TOKEN", + csrf.token(), + "Content-Type", + "application/x-www-form-urlencoded") + .statusCode()); + assertEquals( + 401, + send( + "POST", + "/api/write", + "", + "Cookie", + csrf.cookie() + "; access_token=" + token, + "X-CSRF-TOKEN", + csrf.token()) + .statusCode()); + assertEquals(0, EFFECTS.get()); + } + + @Test + void headerBearerAuthenticationIsNotPersistedIntoSession() throws Exception { + var response = + send("GET", "/fixture/header-session", "", "Authorization", "Bearer " + token()); + assertEquals(200, response.statusCode()); + assertEquals("false", response.body()); + assertEquals( + 401, + send("GET", "/api/read", "", "Cookie", sessionCookie(response, null)).statusCode()); + } + + @Test + void validBearerTakesAuthorityFromTokenDespiteConflictingSession() throws Exception { + assertEquals( + 200, + send( + "POST", + "/api/write", + "", + "Cookie", + authenticatedSession(), + "Authorization", + "Bearer " + token()) + .statusCode()); + assertEquals("synthetic-bearer-user", POLICY_SUBJECT.get()); + assertEquals(1, EFFECTS.get()); + } + + @Test + void currentPolicyDenialStillBlocksValidBearer() throws Exception { + DENY.set(true); + assertEquals( + 403, + send("POST", "/api/write", "", "Authorization", "Bearer " + token()).statusCode()); + assertEquals(0, EFFECTS.get()); + } + + @Test + void invalidBearerCannotFallBackToAuthenticatedSession() throws Exception { + assertEquals( + 401, + send( + "POST", + "/api/write", + "", + "Cookie", + authenticatedSession(), + "Authorization", + "Bearer invalid-synthetic-token") + .statusCode()); + assertEquals(0, EFFECTS.get()); + } + + private record CsrfCredential(String cookie, String token) {} + + private CsrfCredential csrf(String cookie) throws Exception { + var response = + cookie == null + ? send("GET", "/fixture/csrf", "") + : send("GET", "/fixture/csrf", "", "Cookie", cookie); + assertEquals(200, response.statusCode()); + return new CsrfCredential(sessionCookie(response, cookie), response.body()); + } + + private String authenticatedSession() throws Exception { + var response = send("GET", "/fixture/session", ""); + assertEquals(200, response.statusCode()); + return sessionCookie(response, null); + } + + private String sessionCookie(HttpResponse response, String fallback) { + String cookie = + response.headers().allValues("Set-Cookie").stream() + .filter(value -> value.startsWith("SESSION=")) + .map(value -> value.split(";", 2)[0]) + .findFirst() + .orElse(fallback); + assertNotNull(cookie, "Test-owned native session cookie must exist"); + return cookie; + } + + private String token() throws Exception { + var key = keys.activeSigningKey().block(); + var jwt = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.kid()).build(), + new JWTClaimsSet.Builder() + .subject("synthetic-bearer-user") + .expirationTime(Date.from(Instant.now().plusSeconds(120))) + .build()); + jwt.sign(new RSASSASigner((RSAPrivateKey) key.privateKey())); + return jwt.serialize(); + } + + private HttpResponse send(String method, String path, String body, String... headers) + throws Exception { + var builder = + HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)) + .timeout(Duration.ofSeconds(8)) + .method( + method, + body.isEmpty() + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(body)); + for (int index = 0; index < headers.length; index += 2) + builder.header(headers[index], headers[index + 1]); + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build() + .send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } +} From 72544b91ca5e75176fe99136098d7645fe5d4830 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 15:04:08 -0700 Subject: [PATCH 06/44] Quote Batch fixture role data without leaking credentials --- .../batch/fixture/PostgresBatchFixture.java | 64 +++++- .../PostgresBatchRoleProvisioningTest.java | 195 ++++++++++++++++++ 2 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchRoleProvisioningTest.java diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchFixture.java b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchFixture.java index ab5299dee..9b91e6e02 100644 --- a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchFixture.java +++ b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchFixture.java @@ -22,6 +22,8 @@ import org.testcontainers.containers.PostgreSQLContainer; import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.SQLException; import java.util.UUID; import javax.sql.DataSource; @@ -41,15 +43,59 @@ static void openPrivateDatabase() throws Exception { database.start(); role = "batch_runtime_" + UUID.randomUUID().toString().replace("-", ""); password = UUID.randomUUID().toString(); - try (var connection = owner().getConnection(); - var statement = connection.createStatement()) { - statement.execute( - "CREATE ROLE " - + role - + " LOGIN NOSUPERUSER NOBYPASSRLS PASSWORD '" - + password - + "'"); + try (var connection = owner().getConnection()) { + createRuntimeRole(connection, role, password); + } + } + + static void createRuntimeRole(Connection connection, String roleName, String rolePassword) + throws SQLException { + if (invalidCredential(roleName) + || roleName.getBytes(StandardCharsets.UTF_8).length > 63 + || invalidCredential(rolePassword)) { + throw new IllegalArgumentException("Invalid runtime role credentials"); } + try { + try (var statement = connection.createStatement()) { + statement.execute( + """ + CREATE OR REPLACE FUNCTION pg_temp.create_batch_runtime_role(role_name text, role_password text) + RETURNS void + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog + AS $role_setup$ + BEGIN + EXECUTE pg_catalog.format( + 'CREATE ROLE %I LOGIN NOSUPERUSER NOBYPASSRLS PASSWORD %L', + role_name, role_password); + END + $role_setup$ + """); + } + try (var query = + connection.prepareStatement("SELECT pg_temp.create_batch_runtime_role(?, ?)")) { + query.setString(1, roleName); + query.setString(2, rolePassword); + query.execute(); + } + } catch (SQLException failure) { + throw new SQLException( + "Runtime role provisioning failed", + failure.getSQLState(), + failure.getErrorCode()); + } + } + + private static boolean invalidCredential(String value) { + return value == null + || value.isBlank() + || value.indexOf('\0') >= 0 + || value.codePoints() + .anyMatch( + codePoint -> + codePoint >= Character.MIN_SURROGATE + && codePoint <= Character.MAX_SURROGATE); } @AfterAll @@ -82,7 +128,7 @@ protected static DataSource schema(String variant) throws Exception { ddl = ddl.replace( "CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO" - + " CYCLE;", + + " CYCLE;", ""); try (var connection = owner().getConnection(); var statement = connection.createStatement()) { diff --git a/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchRoleProvisioningTest.java b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchRoleProvisioningTest.java new file mode 100644 index 000000000..0c27d0fb7 --- /dev/null +++ b/modules/batch/src/test/java/org/fireflyframework/batch/fixture/PostgresBatchRoleProvisioningTest.java @@ -0,0 +1,195 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.batch.fixture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; +import org.postgresql.ds.PGSimpleDataSource; + +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.util.UUID; + +class PostgresBatchRoleProvisioningTest extends PostgresBatchFixture { + private static String freshRole() { + return "batch_role_" + UUID.randomUUID().toString().replace("-", ""); + } + + private static String freshPassword() { + return UUID.randomUUID().toString(); + } + + private void provision(String name, String secret) throws Exception { + try (var connection = owner().getConnection()) { + createRuntimeRole(connection, name, secret); + } + } + + private long roleCount() throws Exception { + try (var connection = owner().getConnection(); + var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT count(*) FROM pg_catalog.pg_roles")) { + rows.next(); + return rows.getLong(1); + } + } + + private void assertLoginAndPrivileges(String name, String secret) throws Exception { + var runtime = new PGSimpleDataSource(); + runtime.setURL(database.getJdbcUrl()); + runtime.setUser(name); + runtime.setPassword(secret); + try (var connection = runtime.getConnection(); + var query = + connection.prepareStatement( + """ + SELECT current_user, rolcanlogin, rolsuper, rolbypassrls, + rolcreatedb, rolcreaterole, rolreplication, + (SELECT count(*) FROM pg_catalog.pg_auth_members WHERE member = r.oid) + FROM pg_catalog.pg_roles r WHERE rolname = ? + """)) { + query.setString(1, name); + try (var rows = query.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getString(1)).isEqualTo(name); + assertThat(rows.getBoolean(2)).isTrue(); + for (int column = 3; column <= 7; column++) { + assertThat(rows.getBoolean(column)).isFalse(); + } + assertThat(rows.getLong(8)).isZero(); + assertThat(rows.next()).isFalse(); + } + try (var forbidden = connection.createStatement()) { + assertThatExceptionOfType(SQLException.class) + .isThrownBy( + () -> forbidden.execute("CREATE ROLE forbidden_fixture_privilege")) + .satisfies(failure -> assertThat(failure.getSQLState()).isEqualTo("42501")); + assertThatExceptionOfType(SQLException.class) + .isThrownBy( + () -> + forbidden.execute( + "CREATE DATABASE forbidden_fixture_database")) + .satisfies(failure -> assertThat(failure.getSQLState()).isEqualTo("42501")); + } + } + } + + @Test + void ordinaryRuntimeRoleLogsInWithoutElevatedPrivileges() throws Exception { + String name = freshRole(); + String secret = freshPassword(); + provision(name, secret); + assertLoginAndPrivileges(name, secret); + } + + @Test + void quotedRoleAndPasswordRemainDataAndCreateExactlyOneRole() throws Exception { + String name = "batch_😀\"';--" + UUID.randomUUID().toString().replace("-", ""); + String secret = freshPassword() + "'\"; Ω😀"; + long before = roleCount(); + provision(name, secret); + assertThat(roleCount()).isEqualTo(before + 1); + assertLoginAndPrivileges(name, secret); + } + + @Test + void identifierByteLimitPreservesExactMultibyteRole() throws Exception { + String[] names = {"b".repeat(61) + "é", "c".repeat(59) + "😀"}; + for (String name : names) { + assertThat(name.getBytes(StandardCharsets.UTF_8)).hasSize(63); + String secret = freshPassword(); + provision(name, secret); + assertLoginAndPrivileges(name, secret); + } + } + + @Test + void invalidRoleNamesFailBeforeAnyRoleEffect() throws Exception { + long before = roleCount(); + String[] invalid = { + null, + "", + " ", + "a\0b", + "b".repeat(64), + "b".repeat(62) + "é", + "b".repeat(60) + "😀", + "bad\uD800" + }; + for (String name : invalid) { + assertThatIllegalArgumentException() + .isThrownBy(() -> provision(name, freshPassword())) + .withMessage("Invalid runtime role credentials"); + assertThat(roleCount()).isEqualTo(before); + } + } + + @Test + void invalidPasswordsFailBeforeAnyRoleEffect() throws Exception { + long before = roleCount(); + String[] invalid = {null, "", " ", "a\0b", "bad\uDC00"}; + for (String secret : invalid) { + assertThatIllegalArgumentException() + .isThrownBy(() -> provision(freshRole(), secret)) + .withMessage("Invalid runtime role credentials"); + assertThat(roleCount()).isEqualTo(before); + } + } + + @Test + void nativeProvisioningFailureRetainsSqlStateWithoutCredentialDiagnostics() throws Exception { + String name = freshRole(); + String secret = freshPassword(); + provision(name, secret); + assertThatExceptionOfType(SQLException.class) + .isThrownBy(() -> provision(name, secret)) + .satisfies( + failure -> { + assertThat(failure.getSQLState()).isEqualTo("42710"); + var output = new java.io.StringWriter(); + failure.printStackTrace(new java.io.PrintWriter(output)); + assertThat(output.toString()).doesNotContain(secret); + assertThat(failure.getMessage()) + .isEqualTo("Runtime role provisioning failed"); + }); + assertLoginAndPrivileges(name, secret); + } + + @Test + void provisioningFunctionIsTemporaryInvokerWithFixedSearchPath() throws Exception { + try (var connection = owner().getConnection()) { + createRuntimeRole(connection, freshRole(), freshPassword()); + try (var statement = + connection.prepareStatement( + """ + SELECT p.prosecdef, p.proconfig, n.nspname + FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON p.pronamespace = n.oid + WHERE p.oid = pg_catalog.to_regprocedure('pg_temp.create_batch_runtime_role(text,text)') + """); + var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getBoolean(1)).isFalse(); + assertThat((String[]) rows.getArray(2).getArray()) + .containsExactly("search_path=pg_catalog"); + assertThat(rows.getString(3)).startsWith("pg_temp_"); + assertThat(rows.next()).isFalse(); + } + } + } +} From 8d6a6e1c9d192aa31c233fed48eab7f1762e9a9b Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 15:52:11 -0700 Subject: [PATCH 07/44] Avoid eager bean initialization during application metadata discovery --- docs/site/modules/starter-application.md | 4 + modules/starter-application/README.md | 4 + .../metadata/ApplicationMetadataProvider.java | 10 +- .../config/ApplicationStartupTest.java | 38 ++++- .../ApplicationMetadataDiscoveryTest.java | 159 ++++++++++++++++++ 5 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 modules/starter-application/src/test/java/org/fireflyframework/common/application/metadata/ApplicationMetadataDiscoveryTest.java diff --git a/docs/site/modules/starter-application.md b/docs/site/modules/starter-application.md index 11f611e71..c184b2666 100644 --- a/docs/site/modules/starter-application.md +++ b/docs/site/modules/starter-application.md @@ -48,6 +48,10 @@ public class AccountController extends AbstractApplicationController { } ``` +## Application metadata discovery + +Application metadata discovery reads predictable bean types without creating application beans, FactoryBeans or their products. Declare the `@FireflyApplication` main class through a known bean type; the existing immediate-superclass annotation lookup is retained. An unknown product type stays untouched, with existing fallback metadata when no annotation can be resolved. This prevents metadata discovery from reentering Actuator endpoint construction. The guarantee applies to metadata discovery; other integrations retain their own registration and initialization lifecycles. + ## Configuration and wiring The `firefly.application` namespace is bound by [ApplicationLayerProperties.java](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/starter-application/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerProperties.java). Nested configuration is declared in the linked class. Defaults are the field initializers. diff --git a/modules/starter-application/README.md b/modules/starter-application/README.md index 5c5dd8a6b..30e857758 100644 --- a/modules/starter-application/README.md +++ b/modules/starter-application/README.md @@ -44,6 +44,10 @@ public class AccountController extends AbstractApplicationController { } ``` +## Application metadata discovery + +Application metadata discovery reads predictable bean types without creating application beans, FactoryBeans or their products. Declare the `@FireflyApplication` main class through a known bean type; the existing immediate-superclass annotation lookup is retained. An unknown product type stays untouched, with existing fallback metadata when no annotation can be resolved. This prevents metadata discovery from reentering Actuator endpoint construction. The guarantee applies to metadata discovery; other integrations retain their own registration and initialization lifecycles. + ## Configuration The `firefly.application` namespace is bound by [ApplicationLayerProperties.java](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/starter-application/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerProperties.java). Nested configuration is declared in the linked class. Defaults are the field initializers. diff --git a/modules/starter-application/src/main/java/org/fireflyframework/common/application/metadata/ApplicationMetadataProvider.java b/modules/starter-application/src/main/java/org/fireflyframework/common/application/metadata/ApplicationMetadataProvider.java index 8e74488a2..c4920caa7 100644 --- a/modules/starter-application/src/main/java/org/fireflyframework/common/application/metadata/ApplicationMetadataProvider.java +++ b/modules/starter-application/src/main/java/org/fireflyframework/common/application/metadata/ApplicationMetadataProvider.java @@ -112,7 +112,7 @@ private AppMetadata buildAppMetadata(ApplicationContext applicationContext, Envi } /** - * Finds @FireflyApplication annotation in the application context. + * Finds @FireflyApplication without initializing application beans or FactoryBean products. */ private FireflyApplication findFireflyApplicationAnnotation(ApplicationContext applicationContext) { // Get all bean names @@ -120,8 +120,10 @@ private FireflyApplication findFireflyApplicationAnnotation(ApplicationContext a for (String beanName : beanNames) { try { - Object bean = applicationContext.getBean(beanName); - Class beanClass = bean.getClass(); + Class beanClass = applicationContext.getType(beanName, false); + if (beanClass == null) { + continue; + } // Check if class has @FireflyApplication FireflyApplication annotation = beanClass.getAnnotation(FireflyApplication.class); @@ -140,7 +142,7 @@ private FireflyApplication findFireflyApplicationAnnotation(ApplicationContext a } } } catch (Exception e) { - // Ignore beans that can't be retrieved + // Ignore bean types that cannot be resolved without initialization } } diff --git a/modules/starter-application/src/test/java/org/fireflyframework/common/application/config/ApplicationStartupTest.java b/modules/starter-application/src/test/java/org/fireflyframework/common/application/config/ApplicationStartupTest.java index 13e6ebc51..8b324fc7f 100644 --- a/modules/starter-application/src/test/java/org/fireflyframework/common/application/config/ApplicationStartupTest.java +++ b/modules/starter-application/src/test/java/org/fireflyframework/common/application/config/ApplicationStartupTest.java @@ -1,33 +1,61 @@ package org.fireflyframework.common.application.config; +import static org.assertj.core.api.Assertions.assertThat; + +import org.fireflyframework.common.application.metadata.ApplicationMetadataProvider; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.web.context.reactive.ReactiveWebApplicationContext; import org.springframework.cache.CacheManager; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Lazy; -import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.atomic.AtomicReference; @SpringBootTest(classes = ApplicationStartupTest.Application.class) class ApplicationStartupTest { + private static final AtomicReference metadataInitializationState = + new AtomicReference<>(); - @Autowired - CacheManager cacheManager; + @Autowired CacheManager cacheManager; - @Autowired - ApplicationContext applicationContext; + @Autowired ApplicationContext applicationContext; @Test void startsReactiveWithTheSpringCacheInfrastructureRequiredByTheApplicationLayer() { assertThat(applicationContext).isInstanceOf(ReactiveWebApplicationContext.class); assertThat(cacheManager).isNotNull(); + assertThat(metadataInitializationState.get()).isFalse(); } @SpringBootConfiguration @EnableAutoConfiguration static class Application { + @Bean + static BeanPostProcessor metadataInitializationProbe( + ConfigurableListableBeanFactory factory) { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String name) { + if (bean instanceof ApplicationMetadataProvider) { + metadataInitializationState.set( + factory.containsSingleton("deferredApplicationDependency")); + } + return bean; + } + }; + } + + @Bean + @Lazy + Object deferredApplicationDependency() { + return new Object(); + } } } diff --git a/modules/starter-application/src/test/java/org/fireflyframework/common/application/metadata/ApplicationMetadataDiscoveryTest.java b/modules/starter-application/src/test/java/org/fireflyframework/common/application/metadata/ApplicationMetadataDiscoveryTest.java new file mode 100644 index 000000000..6959641f2 --- /dev/null +++ b/modules/starter-application/src/test/java/org/fireflyframework/common/application/metadata/ApplicationMetadataDiscoveryTest.java @@ -0,0 +1,159 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.common.application.metadata; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.context.support.GenericApplicationContext; + +import java.util.concurrent.atomic.AtomicInteger; + +class ApplicationMetadataDiscoveryTest { + private static final AtomicInteger MAIN_CONSTRUCTIONS = new AtomicInteger(); + private static final AtomicInteger UNRELATED_CONSTRUCTIONS = new AtomicInteger(); + private static final AtomicInteger FACTORY_CONSTRUCTIONS = new AtomicInteger(); + private static final AtomicInteger PRODUCT_CONSTRUCTIONS = new AtomicInteger(); + + @BeforeEach + void resetCounters() { + MAIN_CONSTRUCTIONS.set(0); + UNRELATED_CONSTRUCTIONS.set(0); + FACTORY_CONSTRUCTIONS.set(0); + PRODUCT_CONSTRUCTIONS.set(0); + } + + @Test + void findsDeclaredApplicationWithoutCreatingItOrUnrelatedLazyBeans() { + try (var context = new GenericApplicationContext()) { + context.registerBean( + "unrelated", + Object.class, + () -> { + UNRELATED_CONSTRUCTIONS.incrementAndGet(); + return new Object(); + }, + definition -> definition.setLazyInit(true)); + context.registerBean( + "main", DeclaredApplication.class, definition -> definition.setLazyInit(true)); + context.refresh(); + var metadata = + new ApplicationMetadataProvider(context, context.getEnvironment()) + .appMetadata(); + assertThat(metadata.getName()).isEqualTo("declared-application"); + assertThat(metadata.getDomain()).isEqualTo("synthetic-domain"); + assertThat(MAIN_CONSTRUCTIONS.get()).isZero(); + assertThat(UNRELATED_CONSTRUCTIONS.get()).isZero(); + } + } + + @Test + void predictsTypedFactoryProductWithoutInitializingFactoryOrProduct() { + try (var context = new GenericApplicationContext()) { + context.registerBean( + "typed", TypedFactory.class, definition -> definition.setLazyInit(true)); + context.refresh(); + var metadata = + new ApplicationMetadataProvider(context, context.getEnvironment()) + .appMetadata(); + assertThat(metadata.getName()).isEqualTo("declared-application"); + assertThat(FACTORY_CONSTRUCTIONS.get()).isZero(); + assertThat(PRODUCT_CONSTRUCTIONS.get()).isZero(); + assertThat(MAIN_CONSTRUCTIONS.get()).isZero(); + } + } + + @Test + void leavesUnknownFactoryUntouchedAndRetainsDefaultMetadata() { + try (var context = new GenericApplicationContext()) { + context.registerBean( + "unknown", UnknownFactory.class, definition -> definition.setLazyInit(true)); + context.refresh(); + var metadata = + new ApplicationMetadataProvider(context, context.getEnvironment()) + .appMetadata(); + assertThat(metadata.getName()).isEqualTo("unknown-application"); + assertThat(FACTORY_CONSTRUCTIONS.get()).isZero(); + assertThat(PRODUCT_CONSTRUCTIONS.get()).isZero(); + } + } + + @Test + void preservesImmediateSuperclassAnnotationAndItsFields() { + try (var context = new GenericApplicationContext()) { + context.registerBean( + "derived", + DerivedApplication.class, + definition -> definition.setLazyInit(true)); + context.refresh(); + var metadata = + new ApplicationMetadataProvider(context, context.getEnvironment()) + .appMetadata(); + assertThat(metadata.getName()).isEqualTo("declared-application"); + assertThat(metadata.getDescription()) + .isEqualTo("Predictable synthetic application metadata"); + assertThat(metadata.getTeam()).isEqualTo("synthetic-team"); + assertThat(metadata.getDomain()).isEqualTo("synthetic-domain"); + } + } + + @FireflyApplication( + name = "declared-application", + description = "Predictable synthetic application metadata", + domain = "synthetic-domain", + team = "synthetic-team", + owners = {"synthetic-owner"}) + static class DeclaredApplication { + DeclaredApplication() { + MAIN_CONSTRUCTIONS.incrementAndGet(); + } + } + + static class DerivedApplication extends DeclaredApplication {} + + static class TypedFactory implements FactoryBean { + TypedFactory() { + FACTORY_CONSTRUCTIONS.incrementAndGet(); + } + + public DeclaredApplication getObject() { + PRODUCT_CONSTRUCTIONS.incrementAndGet(); + return new DeclaredApplication(); + } + + public Class getObjectType() { + return DeclaredApplication.class; + } + } + + static class UnknownFactory implements FactoryBean { + UnknownFactory() { + FACTORY_CONSTRUCTIONS.incrementAndGet(); + } + + public Object getObject() { + PRODUCT_CONSTRUCTIONS.incrementAndGet(); + return new Object(); + } + + public Class getObjectType() { + return null; + } + } +} From a8e99cebc6d720f9ff7d165d15d12d4f61b874d2 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 16:21:38 -0700 Subject: [PATCH 08/44] Protect client security diagnostics and checked buffer allocation --- docs/site/modules/client.md | 6 + modules/client/README.md | 6 + .../client/security/JwtValidator.java | 7 +- .../security/SecretsEncryptionManager.java | 15 +- .../security/JwtValidatorLoggingTest.java | 208 ++++++++++++++++++ .../SecretsEncryptionManagerSecurityTest.java | 177 +++++++++++++++ 6 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 modules/client/src/test/java/org/fireflyframework/client/security/JwtValidatorLoggingTest.java create mode 100644 modules/client/src/test/java/org/fireflyframework/client/security/SecretsEncryptionManagerSecurityTest.java diff --git a/docs/site/modules/client.md b/docs/site/modules/client.md index 3c64b0fd3..92ed31a84 100644 --- a/docs/site/modules/client.md +++ b/docs/site/modules/client.md @@ -28,6 +28,12 @@ The service may call a document-verification partner during a later onboarding s ``` +## Security helper diagnostics + +Secret storage, missing-secret, and removal events use fixed messages without caller-supplied secret names. Successful JWT validation does not log the verified subject. A non-null subject must remain text; other shapes receive a constant `JwtValidationException` diagnostic. Missing/null subjects and the existing required-claim presence rule retain their behavior. Encryption checks the combined IV and ciphertext length before allocating its output buffer; this check does not impose a new plaintext or configured-IV size budget. The cipher format and validation APIs are unchanged. + +The generated-key encoding contract remains **open**: `create()` converts random bytes through ISO-8859-1 before UTF-8 key loading, while `generateMasterKey()` returns Base64 and `rotateKey(String)` expects raw 32-byte UTF-8 text. These factory/generated-key paths are not qualified by this correction. Cipher compatibility tests use an explicitly supplied 32-byte ASCII master key; production key material belongs in an application-managed secret source. + ## Quick start The injection examples use Lombok `@RequiredArgsConstructor`. Add its provided dependency and annotation processor as described in [Constructor injection](../maven.md#constructor-injection). diff --git a/modules/client/README.md b/modules/client/README.md index 5de492bb0..dcecb9205 100644 --- a/modules/client/README.md +++ b/modules/client/README.md @@ -24,6 +24,12 @@ The service may call a document-verification partner during a later onboarding s ``` +## Security helper diagnostics + +Secret storage, missing-secret, and removal events use fixed messages without caller-supplied secret names. Successful JWT validation does not log the verified subject. A non-null subject must remain text; other shapes receive a constant `JwtValidationException` diagnostic. Missing/null subjects and the existing required-claim presence rule retain their behavior. Encryption checks the combined IV and ciphertext length before allocating its output buffer; this check does not impose a new plaintext or configured-IV size budget. The cipher format and validation APIs are unchanged. + +The generated-key encoding contract remains **open**: `create()` converts random bytes through ISO-8859-1 before UTF-8 key loading, while `generateMasterKey()` returns Base64 and `rotateKey(String)` expects raw 32-byte UTF-8 text. These factory/generated-key paths are not qualified by this correction. Cipher compatibility tests use an explicitly supplied 32-byte ASCII master key; production key material belongs in an application-managed secret source. + ## Quick start The injection examples use Lombok `@RequiredArgsConstructor`. Add its provided dependency and annotation processor as described in [Constructor injection](../../docs/site/maven.md#constructor-injection). diff --git a/modules/client/src/main/java/org/fireflyframework/client/security/JwtValidator.java b/modules/client/src/main/java/org/fireflyframework/client/security/JwtValidator.java index 7ee81e3de..164c5bd56 100644 --- a/modules/client/src/main/java/org/fireflyframework/client/security/JwtValidator.java +++ b/modules/client/src/main/java/org/fireflyframework/client/security/JwtValidator.java @@ -135,7 +135,7 @@ public JwtClaims validate(String token) throws JwtValidationException { // Validate claims validateClaims(claims); - log.debug("JWT validated successfully for subject: {}", claims.getSubject()); + log.debug("JWT validated successfully"); return claims; } @@ -229,6 +229,11 @@ private void validateClaims(JwtClaims claims) throws JwtValidationException { throw new JwtValidationException("Missing required claim: " + requiredClaim); } } + + Object subject = claims.getClaim("sub"); + if (subject != null && !(subject instanceof String)) { + throw new JwtValidationException("Invalid subject claim"); + } } /** diff --git a/modules/client/src/main/java/org/fireflyframework/client/security/SecretsEncryptionManager.java b/modules/client/src/main/java/org/fireflyframework/client/security/SecretsEncryptionManager.java index 5b3473596..13bcf3fa6 100644 --- a/modules/client/src/main/java/org/fireflyframework/client/security/SecretsEncryptionManager.java +++ b/modules/client/src/main/java/org/fireflyframework/client/security/SecretsEncryptionManager.java @@ -114,7 +114,7 @@ public String encrypt(String plaintext) { byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // Combine IV and ciphertext - ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + ciphertext.length); + ByteBuffer byteBuffer = ByteBuffer.allocate(checkedCombinedLength(iv.length, ciphertext.length)); byteBuffer.put(iv); byteBuffer.put(ciphertext); @@ -180,7 +180,7 @@ public void storeSecret(String key, String value) { } String encrypted = encrypt(value); encryptedSecrets.put(key, encrypted); - log.debug("Stored encrypted secret: {}", key); + log.debug("Stored encrypted secret"); } /** @@ -192,7 +192,7 @@ public void storeSecret(String key, String value) { public String getSecret(String key) { String encrypted = encryptedSecrets.get(key); if (encrypted == null) { - log.warn("Secret not found: {}", key); + log.warn("Secret not found"); return null; } return decrypt(encrypted); @@ -207,7 +207,7 @@ public String getSecret(String key) { public boolean removeSecret(String key) { boolean removed = encryptedSecrets.remove(key) != null; if (removed) { - log.debug("Removed secret: {}", key); + log.debug("Removed secret"); } return removed; } @@ -267,6 +267,13 @@ public SecretsEncryptionManager rotateKey(String newMasterKey) { return newManager; } + static int checkedCombinedLength(int ivLength, int ciphertextLength) { + if (ivLength < 0 || ciphertextLength < 0) { + throw new IllegalArgumentException("Lengths cannot be negative"); + } + return Math.addExact(ivLength, ciphertextLength); + } + /** * Gets the secret key from the master key. * diff --git a/modules/client/src/test/java/org/fireflyframework/client/security/JwtValidatorLoggingTest.java b/modules/client/src/test/java/org/fireflyframework/client/security/JwtValidatorLoggingTest.java new file mode 100644 index 000000000..85270b9ec --- /dev/null +++ b/modules/client/src/test/java/org/fireflyframework/client/security/JwtValidatorLoggingTest.java @@ -0,0 +1,208 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.client.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import javax.crypto.KeyGenerator; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +class JwtValidatorLoggingTest { + private Logger logger; + private Level previousLevel; + private ListAppender appender; + private String signingKey; + + @BeforeEach + void captureLogs() throws Exception { + logger = (Logger) LoggerFactory.getLogger(JwtValidator.class); + previousLevel = logger.getLevel(); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.DEBUG); + var generator = KeyGenerator.getInstance("HmacSHA256"); + generator.init(256); + signingKey = HexFormat.of().formatHex(generator.generateKey().getEncoded()); + } + + @AfterEach + void restoreLogs() { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + appender.stop(); + } + + @Test + void validatesTheRealSignatureAndReturnsClaimsWithoutLoggingSubjectOrToken() throws Exception { + String subject = "synthetic-sensitive-subject-Ω"; + String token = + signedToken(subject, signingKey, Instant.now().plusSeconds(300).getEpochSecond()); + var claims = validator().validate(token); + assertThat(claims.getSubject()).isEqualTo(subject); + assertThat(claims.getIssuer()).isEqualTo("https://synthetic-issuer.example"); + assertThat(claims.getAudience()).isEqualTo("synthetic-audience"); + assertThat(appender.list).isNotEmpty(); + for (var event : appender.list) { + assertThat(event.getFormattedMessage()).doesNotContain(subject, token, signingKey); + if (event.getArgumentArray() != null) { + assertThat(Arrays.toString(event.getArgumentArray())) + .doesNotContain(subject, token, signingKey); + } + } + } + + @Test + void rejectsAnotherKeysSignatureWithoutEmittingASuccessLog() throws Exception { + String token = + signedToken( + "synthetic-untrusted-subject", + signingKey + "different", + Instant.now().plusSeconds(300).getEpochSecond()); + assertThatThrownBy(() -> validator().validate(token)) + .isInstanceOf(JwtValidator.JwtValidationException.class) + .hasMessage("Invalid JWT signature"); + assertThat(appender.list).isEmpty(); + } + + @Test + void rejectsExpiredSignedClaimsWithoutEmittingASuccessLog() throws Exception { + String token = + signedToken( + "synthetic-expired-subject", + signingKey, + Instant.now().minusSeconds(300).getEpochSecond()); + assertThatThrownBy(() -> validator().validate(token)) + .isInstanceOf(JwtValidator.JwtValidationException.class) + .hasMessage("JWT has expired"); + assertThat(appender.list).isEmpty(); + } + + @ParameterizedTest + @MethodSource("nontextSubjects") + void rejectsSignedNontextSubjectsWithAConstantDiagnostic(Object subject) throws Exception { + Map claims = baseClaims(); + claims.put("sub", subject); + String token = signedClaims(claims, signingKey); + assertThatThrownBy(() -> validator().validate(token)) + .isInstanceOf(JwtValidator.JwtValidationException.class) + .hasMessage("Invalid subject claim"); + assertThat(appender.list).isEmpty(); + } + + static Stream nontextSubjects() { + return Stream.of(42, true, Map.of("synthetic", "object"), List.of("synthetic-array")); + } + + @Test + void preservesAnAbsentOptionalSubject() throws Exception { + assertThat(validator().validate(signedClaims(baseClaims(), signingKey)).getSubject()) + .isNull(); + } + + @Test + void preservesAnExplicitNullOptionalSubject() throws Exception { + Map claims = baseClaims(); + claims.put("sub", null); + assertThat(validator().validate(signedClaims(claims, signingKey)).getSubject()).isNull(); + } + + @Test + void preservesTheExistingRequiredClaimPresenceRule() throws Exception { + var requiringSubject = + JwtValidator.builder() + .secret(signingKey) + .requiredClaims(java.util.Set.of("sub")) + .build(); + String absent = signedClaims(baseClaims(), signingKey); + assertThatThrownBy(() -> requiringSubject.validate(absent)) + .isInstanceOf(JwtValidator.JwtValidationException.class) + .hasMessage("Missing required claim: sub"); + Map explicitNull = baseClaims(); + explicitNull.put("sub", null); + assertThat(requiringSubject.validate(signedClaims(explicitNull, signingKey)).getSubject()) + .isNull(); + } + + private static Map baseClaims() { + return new LinkedHashMap<>( + Map.of( + "iss", + "https://synthetic-issuer.example", + "aud", + "synthetic-audience", + "exp", + Instant.now().plusSeconds(300).getEpochSecond())); + } + + private JwtValidator validator() { + return JwtValidator.builder() + .secret(signingKey) + .issuer("https://synthetic-issuer.example") + .audience("synthetic-audience") + .build(); + } + + private static String signedToken(String subject, String key, long expiration) + throws Exception { + Map claims = baseClaims(); + claims.put("sub", subject); + claims.put("exp", expiration); + return signedClaims(claims, key); + } + + private static String signedClaims(Map claims, String key) throws Exception { + var encoder = Base64.getUrlEncoder().withoutPadding(); + String header = + encoder.encodeToString( + "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8)); + String payload = encoder.encodeToString(new ObjectMapper().writeValueAsBytes(claims)); + String signingInput = header + "." + payload; + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return signingInput + + "." + + encoder.encodeToString( + mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/modules/client/src/test/java/org/fireflyframework/client/security/SecretsEncryptionManagerSecurityTest.java b/modules/client/src/test/java/org/fireflyframework/client/security/SecretsEncryptionManagerSecurityTest.java new file mode 100644 index 000000000..1de71478b --- /dev/null +++ b/modules/client/src/test/java/org/fireflyframework/client/security/SecretsEncryptionManagerSecurityTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.client.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; + +class SecretsEncryptionManagerSecurityTest { + private Logger logger; + private Level previousLevel; + private ListAppender appender; + + @BeforeEach + void captureLogs() { + logger = (Logger) LoggerFactory.getLogger(SecretsEncryptionManager.class); + previousLevel = logger.getLevel(); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.DEBUG); + } + + @AfterEach + void restoreLogs() { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + appender.stop(); + } + + @Test + void encryptsUnicodeUsingTheExistingIvCiphertextFormat() { + var manager = manager(); + String plaintext = "Synthetic text Ω \uD83D\uDE80"; + String encrypted = manager.encrypt(plaintext); + assertThat(manager.decrypt(encrypted)).isEqualTo(plaintext); + assertThat(Base64.getDecoder().decode(encrypted)) + .hasSize( + 12 + + plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8).length + + 16); + assertPrivateLogs(plaintext, encrypted); + } + + @Test + void rejectsModifiedAuthenticatedCiphertext() { + var manager = manager(); + byte[] ciphertext = Base64.getDecoder().decode(manager.encrypt("synthetic tamper control")); + ciphertext[ciphertext.length - 1] ^= 1; + assertThatThrownBy(() -> manager.decrypt(Base64.getEncoder().encodeToString(ciphertext))) + .isInstanceOf(RuntimeException.class) + .hasMessage("Decryption failed"); + } + + @Test + void storesAndRetrievesWithoutLoggingTheCallerSuppliedSecretName() { + var manager = manager(); + String label = "synthetic-sensitive-storage-label"; + String value = "synthetic-storage-value-Ω"; + manager.storeSecret(label, value); + assertThat(manager.getSecret(label)).isEqualTo(value); + assertThat(manager.hasSecret(label)).isTrue(); + assertPrivateLogs(label, value); + } + + @Test + void missingSecretDoesNotLogTheCallerSuppliedName() { + String label = "synthetic-sensitive-missing-label"; + assertThat(manager().getSecret(label)).isNull(); + assertThat(appender.list).anyMatch(event -> event.getLevel() == Level.WARN); + assertPrivateLogs(label); + } + + @Test + void removalDoesNotLogTheCallerSuppliedName() { + var manager = manager(); + String label = "synthetic-sensitive-removal-label"; + manager.storeSecret(label, "synthetic-removal-value"); + appender.list.clear(); + assertThat(manager.removeSecret(label)).isTrue(); + assertThat(manager.removeSecret(label)).isFalse(); + assertPrivateLogs(label); + } + + @Test + void rotationPreservesBothManagersWithoutLoggingSecretNames() { + var original = manager(); + String label = "synthetic-sensitive-rotation-label"; + String value = "synthetic-rotation-value-Ω"; + original.storeSecret(label, value); + appender.list.clear(); + var rotated = original.rotateKey(rawAsciiKey()); + assertThat(rotated.getSecret(label)).isEqualTo(value); + assertThat(original.getSecret(label)).isEqualTo(value); + assertThat(rotated.getSecretCount()).isEqualTo(1); + assertPrivateLogs(label, value); + } + + @Test + void checkedCombinedLengthPreservesExactNonnegativeBoundariesWithoutAllocating() { + assertThat(SecretsEncryptionManager.checkedCombinedLength(0, 0)).isZero(); + assertThat(SecretsEncryptionManager.checkedCombinedLength(12, 32)).isEqualTo(44); + assertThat(SecretsEncryptionManager.checkedCombinedLength(12, Integer.MAX_VALUE - 12)) + .isEqualTo(Integer.MAX_VALUE); + } + + @Test + void checkedCombinedLengthRejectsOverflowBeforeCombinedAllocation() { + assertThatThrownBy( + () -> + SecretsEncryptionManager.checkedCombinedLength( + 12, Integer.MAX_VALUE - 11)) + .isInstanceOf(ArithmeticException.class); + assertThatThrownBy( + () -> + SecretsEncryptionManager.checkedCombinedLength( + Integer.MAX_VALUE, Integer.MAX_VALUE)) + .isInstanceOf(ArithmeticException.class); + } + + @Test + void checkedCombinedLengthRejectsNegativeLengths() { + assertThatThrownBy(() -> SecretsEncryptionManager.checkedCombinedLength(-1, 12)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SecretsEncryptionManager.checkedCombinedLength(12, -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + private void assertPrivateLogs(String... sensitiveValues) { + assertThat(appender.list).isNotEmpty(); + for (var event : appender.list) { + assertThat(event.getFormattedMessage()).doesNotContain(sensitiveValues); + if (event.getArgumentArray() != null) { + assertThat(Arrays.toString(event.getArgumentArray())) + .doesNotContain(sensitiveValues); + } + } + } + + private static SecretsEncryptionManager manager() { + return SecretsEncryptionManager.builder().masterKey(rawAsciiKey()).build(); + } + + private static String rawAsciiKey() { + byte[] random = new byte[16]; + new SecureRandom().nextBytes(random); + return HexFormat.of().formatHex(random); + } +} From 978a53b12af4c999ce3bd5595d94c5b6fa87a9a7 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 17:21:01 -0700 Subject: [PATCH 09/44] Preserve long precision in condition report counters --- .../service/ConditionsReportService.java | 8 +- .../service/ConditionsReportServiceTest.java | 106 ++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 modules/web/src/test/java/org/fireflyframework/web/conditions/service/ConditionsReportServiceTest.java diff --git a/modules/web/src/main/java/org/fireflyframework/web/conditions/service/ConditionsReportService.java b/modules/web/src/main/java/org/fireflyframework/web/conditions/service/ConditionsReportService.java index dbeaec629..6207a503e 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/conditions/service/ConditionsReportService.java +++ b/modules/web/src/main/java/org/fireflyframework/web/conditions/service/ConditionsReportService.java @@ -388,8 +388,8 @@ private void printSummary(Map> categorized) { sb.append(line("=")); sb.append("\n"); - int totalMatched = 0; - int totalNotMatched = 0; + long totalMatched = 0; + long totalNotMatched = 0; for (Map.Entry> entry : categorized.entrySet()) { long matched = entry.getValue().stream().filter(ConditionEntry::isMatched).count(); @@ -426,8 +426,8 @@ private void printDetailedReport(Map> categorized) sb.append(line("=")); sb.append("\n"); - int totalMatched = 0; - int totalNotMatched = 0; + long totalMatched = 0; + long totalNotMatched = 0; for (Map.Entry> categoryEntry : categorized.entrySet()) { String category = categoryEntry.getKey(); diff --git a/modules/web/src/test/java/org/fireflyframework/web/conditions/service/ConditionsReportServiceTest.java b/modules/web/src/test/java/org/fireflyframework/web/conditions/service/ConditionsReportServiceTest.java new file mode 100644 index 000000000..b47bd0e46 --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/conditions/service/ConditionsReportServiceTest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.web.conditions.service; + +import org.fireflyframework.web.conditions.config.ConditionsReportProperties; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Condition; +import org.springframework.context.support.GenericApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(OutputCaptureExtension.class) +class ConditionsReportServiceTest { + + private static final Condition CONDITION = (context, metadata) -> true; + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void reportsMatchedAndUnmatchedTotalsAcrossCategories(boolean summaryOnly, CapturedOutput output) { + ConditionsReportProperties properties = properties(summaryOnly); + try (GenericApplicationContext context = new GenericApplicationContext()) { + recordConditions(context); + + new ConditionsReportService(properties).generateReport(context); + + assertThat(output.getOut()).contains(summaryOnly + ? "TOTAL: 2 active, 2 inactive" + : "SUMMARY: 2 configurations active, 2 inactive"); + if (summaryOnly) { + assertThat(output.getOut()).containsPattern("Firefly Custom +1 active \\| +1 inactive"); + assertThat(output.getOut()).containsPattern("Web & HTTP +1 active"); + assertThat(output.getOut()).containsPattern("Databases & Data Access +0 active \\| +1 inactive"); + } else { + assertThat(output.getOut()).contains( + "FIREFLY CUSTOM (1 active, 1 inactive)", + "WEB & HTTP (1 active, 0 inactive)", + "DATABASES & DATA ACCESS (0 active, 1 inactive)"); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void excludesHiddenNegativeConfigurationsFromTotals(boolean summaryOnly, CapturedOutput output) { + ConditionsReportProperties properties = properties(summaryOnly); + properties.setShowNegative(false); + try (GenericApplicationContext context = new GenericApplicationContext()) { + recordConditions(context); + + new ConditionsReportService(properties).generateReport(context); + + assertThat(output.getOut()).contains(summaryOnly + ? "TOTAL: 2 active, 0 inactive" + : "SUMMARY: 2 configurations active, 0 inactive"); + assertThat(output.getOut()).doesNotContain("Databases & Data Access", "DATABASES & DATA ACCESS", "[INACTIVE]"); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void reportsZeroTotalsForAnEmptyReport(boolean summaryOnly, CapturedOutput output) { + try (GenericApplicationContext context = new GenericApplicationContext()) { + new ConditionsReportService(properties(summaryOnly)).generateReport(context); + + assertThat(output.getOut()).contains(summaryOnly + ? "TOTAL: 0 active, 0 inactive" + : "SUMMARY: 0 configurations active, 0 inactive"); + } + } + + private ConditionsReportProperties properties(boolean summaryOnly) { + ConditionsReportProperties properties = new ConditionsReportProperties(); + properties.setSummaryOnly(summaryOnly); + properties.setUseColors(false); + properties.setShowDetails(false); + return properties; + } + + private void recordConditions(GenericApplicationContext context) { + ConditionEvaluationReport report = ConditionEvaluationReport.get(context.getBeanFactory()); + report.recordConditionEvaluation("org.fireflyframework.ActiveConfiguration", CONDITION, ConditionOutcome.match()); + report.recordConditionEvaluation("org.fireflyframework.InactiveConfiguration", CONDITION, ConditionOutcome.noMatch("disabled")); + report.recordConditionEvaluation("org.example.web.WebConfiguration", CONDITION, ConditionOutcome.match()); + report.recordConditionEvaluation("org.example.data.JdbcConfiguration", CONDITION, ConditionOutcome.noMatch("disabled")); + } +} From 95601ccbe527161c271cccd2080830e61ff32994 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 23 Oct 2026 23:29:50 -0700 Subject: [PATCH 10/44] Preserve enum criteria types for configured R2DBC converters --- .../core/filters/FilterUtils.java | 14 +-- .../FilterUtilsEnumConversionTest.java | 92 +++++++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterUtilsEnumConversionTest.java diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterUtils.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterUtils.java index a9fea1199..1bd3ce34a 100644 --- a/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterUtils.java +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterUtils.java @@ -223,17 +223,9 @@ else if (value instanceof String && !((String) value).isEmpty()) { criteriaList.add(Criteria.where(field.getName()).like("%" + stringValue + "%")); } } - // Handle Enum types - else if (field.getType().isEnum() || value.getClass().isEnum()) { - // For Enum types, we use the exact match with the Enum's name or the Enum itself - // depending on how the database stores the enum (as string or as ordinal) - if (value instanceof String) { - // If the value is already a String, use it directly - criteriaList.add(Criteria.where(field.getName()).is(value)); - } else { - // If the value is an Enum, use its name for matching - criteriaList.add(Criteria.where(field.getName()).is(((Enum) value).name())); - } + // Retain the enum type so registered persistence converters choose its representation. + else if (value instanceof Enum) { + criteriaList.add(Criteria.where(field.getName()).is(value)); } // Exact match for other types of fields else if (!(value instanceof String)) { diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterUtilsEnumConversionTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterUtilsEnumConversionTest.java new file mode 100644 index 000000000..fa236803d --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterUtilsEnumConversionTest.java @@ -0,0 +1,92 @@ +package org.fireflyframework.core.filters; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import io.r2dbc.postgresql.codec.PostgresqlObjectId; +import io.r2dbc.spi.Parameter; +import io.r2dbc.spi.Parameters; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.annotation.Id; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.r2dbc.convert.MappingR2dbcConverter; +import org.springframework.data.r2dbc.convert.R2dbcCustomConversions; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.core.StatementMapper; +import org.springframework.data.r2dbc.dialect.PostgresDialect; +import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.r2dbc.core.binding.BindTarget; + +class FilterUtilsEnumConversionTest { + @BeforeEach + void initializeCriteriaFactory() { + FilterUtils.initializeTemplate(mock(R2dbcEntityTemplate.class)); + } + + @Test + void scalarEnumUsesTheConfiguredNativeEnumWriter() { + assertNativeLabel(new ScalarFilter(Status.POSTED)); + } + + @Test + void enumCollectionUsesTheSameConfiguredWriter() { + assertNativeLabel(new CollectionFilter(List.of(Status.POSTED))); + } + + @Test + void defaultEnumPersistenceRetainsItsStandardName() { + var conversions = R2dbcCustomConversions.of(PostgresDialect.INSTANCE, List.of()); + assertEquals(List.of("POSTED"), bindings(new ScalarFilter(Status.POSTED), conversions)); + } + + private static void assertNativeLabel(Object input) { + var conversions = R2dbcCustomConversions.of(PostgresDialect.INSTANCE, new StatusWriter()); + var bindings = bindings(input, conversions); + assertEquals(1, bindings.size()); + var parameter = assertInstanceOf(Parameter.class, bindings.getFirst()); + assertEquals("posted", parameter.getValue()); + assertEquals(PostgresqlObjectId.UNSPECIFIED, parameter.getType()); + } + + private static List bindings(Object input, R2dbcCustomConversions conversions) { + var request = FilterRequest.builder().filters(input).build(); + var criteria = FilterUtils.createFilter(Entry.class, Function.identity()).getCriteria(request); + var context = new R2dbcMappingContext(); + context.setSimpleTypeHolder(conversions.getSimpleTypeHolder()); + var converter = new MappingR2dbcConverter(context, conversions); + var mapper = StatementMapper.create(PostgresDialect.INSTANCE, converter).forType(Entry.class); + var operation = assertDoesNotThrow(() -> mapper.getMappedObject( + mapper.createSelect("enum_conversion_fixture").withProjection("id").withCriteria(criteria)), + "Enum filters must reach the configured persistence writer with their original type"); + var bindings = new ArrayList(); + operation.bindTo(new BindTarget() { + public void bind(String name, Object value) { bindings.add(value); } + public void bind(int index, Object value) { bindings.add(value); } + public void bindNull(String name, Class type) { fail("Unexpected null binding"); } + public void bindNull(int index, Class type) { fail("Unexpected null binding"); } + }); + return bindings; + } + + private enum Status { POSTED } + private record ScalarFilter(Status status) {} + private record CollectionFilter(List status) {} + + @Table("enum_conversion_fixture") + private record Entry(@Id UUID id, Status status) {} + + @WritingConverter + private static final class StatusWriter implements Converter { + public Parameter convert(Status status) { + return Parameters.in(PostgresqlObjectId.UNSPECIFIED, status.name().toLowerCase(Locale.ROOT)); + } + } +} From f9097217602071bf1da278b866faaa8a1e214b4c Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sat, 24 Oct 2026 00:15:19 -0700 Subject: [PATCH 11/44] Use cryptographic randomness for generated account numbers --- .../core/dsl/visitor/ExpressionEvaluator.java | 2 +- .../core/dsl/AccountNumberGenerationTest.java | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 modules/rule-engine/fireflyframework-rule-engine-core/src/test/java/org/fireflyframework/rules/core/dsl/AccountNumberGenerationTest.java diff --git a/modules/rule-engine/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/visitor/ExpressionEvaluator.java b/modules/rule-engine/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/visitor/ExpressionEvaluator.java index b4f1a976d..98dc00e07 100644 --- a/modules/rule-engine/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/visitor/ExpressionEvaluator.java +++ b/modules/rule-engine/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/visitor/ExpressionEvaluator.java @@ -2115,7 +2115,7 @@ private Object formatPercentage(Object[] args) { private Object generateAccountNumber(Object[] args) { // Generate a random 12-digit account number - java.util.Random random = new java.util.Random(); + java.security.SecureRandom random = new java.security.SecureRandom(); StringBuilder accountNumber = new StringBuilder(); for (int i = 0; i < 12; i++) { diff --git a/modules/rule-engine/fireflyframework-rule-engine-core/src/test/java/org/fireflyframework/rules/core/dsl/AccountNumberGenerationTest.java b/modules/rule-engine/fireflyframework-rule-engine-core/src/test/java/org/fireflyframework/rules/core/dsl/AccountNumberGenerationTest.java new file mode 100644 index 000000000..a55383c65 --- /dev/null +++ b/modules/rule-engine/fireflyframework-rule-engine-core/src/test/java/org/fireflyframework/rules/core/dsl/AccountNumberGenerationTest.java @@ -0,0 +1,42 @@ +package org.fireflyframework.rules.core.dsl; + +import org.fireflyframework.rules.core.dsl.expression.FunctionCallExpression; +import org.fireflyframework.rules.core.dsl.visitor.EvaluationContext; +import org.fireflyframework.rules.core.dsl.visitor.ExpressionEvaluator; +import org.junit.jupiter.api.Test; + +import java.security.SecureRandom; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.when; + +class AccountNumberGenerationTest { + @Test + void usesCryptographicRandomnessAndPreservesLeadingZeroes() { + try (var generators = mockConstruction(SecureRandom.class, + (generator, context) -> when(generator.nextInt(10)) + .thenReturn(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1))) { + var result = accountNumber(); + + assertEquals(1, generators.constructed().size()); + assertEquals("012345678901", result); + } + } + + @Test + void realGeneratorRetainsTheTwelveDigitStringContract() { + for (int index = 0; index < 64; index++) { + var result = assertInstanceOf(String.class, accountNumber()); + assertTrue(result.matches("[0-9]{12}")); + } + } + + private static Object accountNumber() { + var evaluator = new ExpressionEvaluator(new EvaluationContext()); + return new FunctionCallExpression(null, "generate_account_number", List.of()).accept(evaluator); + } +} From b079ed151c88af5897d55b4f6a8c7dff194f6f79 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sat, 24 Oct 2026 01:07:08 -0700 Subject: [PATCH 12/44] Project configured validated JWT tenants into authenticated principals --- modules/security-resource-server/README.md | 4 ++ .../rs/JwtToFireflyPrincipalConverter.java | 42 +++++++++++-- .../rs/ResourceServerAutoConfiguration.java | 24 +++++++- .../security/rs/ResourceServerProperties.java | 7 +++ .../rs/TenantPrincipalProjectionTest.java | 40 +++++++++++++ .../rs/TenantProjectionConfigurationTest.java | 37 ++++++++++++ .../TenantProjectionHttpIntegrationTest.java | 60 +++++++++++++++++++ 7 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantPrincipalProjectionTest.java create mode 100644 modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantProjectionConfigurationTest.java create mode 100644 modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantProjectionHttpIntegrationTest.java diff --git a/modules/security-resource-server/README.md b/modules/security-resource-server/README.md index b686e03f3..4347c77ea 100644 --- a/modules/security-resource-server/README.md +++ b/modules/security-resource-server/README.md @@ -91,6 +91,10 @@ The `firefly.security.resource-server` namespace is bound by [ResourceServerProp | `firefly.security.resource-server.audiences` | `List` | `new ArrayList<>()` | | `firefly.security.resource-server.authority-prefix` | `String` | `""` | | `firefly.security.resource-server.role-claim-paths` | `List` | `new ArrayList<>()` | +| `firefly.security.resource-server.tenant-claim-paths` | `List` | `new ArrayList<>()` | +| `firefly.security.resource-server.tenant-required` | `boolean` | `false` | + +Set `tenant-claim-paths: [tenant_id]` to project exactly one distinct tenant from validated token claims into `SecurityPrincipal.tenantId`; request headers are not used. The empty default preserves the existing unconfigured principal. With `tenant-required: true`, unresolved or ambiguous tenants reject authentication, and missing or invalid configured paths fail startup. Optional configured projection yields a null tenant when unresolved or ambiguous. Resolver errors reject authentication in either mode without exposing claim values. ## API overview diff --git a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/JwtToFireflyPrincipalConverter.java b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/JwtToFireflyPrincipalConverter.java index 5fcf4561a..e7bb56845 100644 --- a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/JwtToFireflyPrincipalConverter.java +++ b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/JwtToFireflyPrincipalConverter.java @@ -18,10 +18,15 @@ import org.fireflyframework.security.api.domain.SecurityPrincipal; import org.fireflyframework.security.core.context.PrincipalFactory; +import org.fireflyframework.security.spi.TenantResolverPort; import org.fireflyframework.security.webflux.authentication.FireflyAuthenticationToken; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; + +import java.util.Objects; import reactor.core.publisher.Mono; /** @@ -32,15 +37,44 @@ public class JwtToFireflyPrincipalConverter implements Converter> { private final PrincipalFactory principalFactory; + private final TenantResolverPort tenantResolver; + private final boolean tenantRequired; public JwtToFireflyPrincipalConverter(PrincipalFactory principalFactory) { - this.principalFactory = principalFactory; + this(principalFactory, null, false); + } + + public JwtToFireflyPrincipalConverter(PrincipalFactory principalFactory, + TenantResolverPort tenantResolver, boolean tenantRequired) { + this.principalFactory = Objects.requireNonNull(principalFactory); + if (tenantRequired && tenantResolver == null) { + throw new IllegalArgumentException("A configured tenant resolver is required"); + } + this.tenantResolver = tenantResolver; + this.tenantRequired = tenantRequired; } @Override public Mono convert(Jwt jwt) { - String issuer = jwt.getIssuer() == null ? null : jwt.getIssuer().toString(); - SecurityPrincipal principal = principalFactory.fromClaims(jwt.getSubject(), issuer, jwt.getClaims()); - return Mono.just(new FireflyAuthenticationToken(principal)); + return Mono.defer(() -> { + String issuer = jwt.getIssuer() == null ? null : jwt.getIssuer().toString(); + SecurityPrincipal principal = principalFactory.fromClaims(jwt.getSubject(), issuer, jwt.getClaims()); + if (tenantResolver == null) { + return Mono.just(new FireflyAuthenticationToken(principal)); + } + return Mono.defer(() -> tenantResolver.resolveTenant(principal)) + .filter(tenant -> !tenant.isBlank()) + .map(tenant -> + new FireflyAuthenticationToken(principal.toBuilder().tenantId(tenant).build())) + .switchIfEmpty(Mono.defer(() -> tenantRequired + ? Mono.error(tenantFailure()) + : Mono.just(new FireflyAuthenticationToken(principal.toBuilder().tenantId(null).build())))) + .onErrorMap(error -> tenantFailure()); + }); + } + + private static OAuth2AuthenticationException tenantFailure() { + return new OAuth2AuthenticationException(new OAuth2Error("invalid_token", + "Token tenant could not be resolved", null)); } } diff --git a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java index 7ca7f76bc..ce3da6b46 100644 --- a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java +++ b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerAutoConfiguration.java @@ -25,6 +25,7 @@ import org.fireflyframework.security.core.key.InMemoryKeyManagementAdapter; import org.fireflyframework.security.core.policy.EmbeddedPolicyDecisionAdapter; import org.fireflyframework.security.core.policy.PolicyRule; +import org.fireflyframework.security.core.tenant.ConfigurableTenantResolver; import org.fireflyframework.security.spi.AuditEventPort; import org.fireflyframework.security.spi.AuthorityMappingPort; import org.fireflyframework.security.spi.KeyManagementPort; @@ -122,8 +123,27 @@ public SecurityContextPort fireflySecurityContextPort() { @Bean @ConditionalOnMissingBean - public Converter> fireflyJwtAuthenticationConverter(PrincipalFactory principalFactory) { - return new JwtToFireflyPrincipalConverter(principalFactory); + public Converter> fireflyJwtAuthenticationConverter( + PrincipalFactory principalFactory, ResourceServerProperties properties) { + List paths = properties.getTenantClaimPaths(); + if (paths == null || paths.stream().anyMatch(path -> path == null || path.isBlank() + || java.util.Arrays.stream(path.split("\\.", -1)).anyMatch(String::isBlank))) { + throw new IllegalArgumentException("Valid tenant claim paths are required"); + } + if (paths.isEmpty()) { + if (properties.isTenantRequired()) { + throw new IllegalArgumentException("Required tenant projection needs configured claim paths"); + } + return new JwtToFireflyPrincipalConverter(principalFactory); + } + return new JwtToFireflyPrincipalConverter(principalFactory, + new ConfigurableTenantResolver(paths), properties.isTenantRequired()); + } + + /** Preserves direct callers that use the unconfigured single-tenant projection. */ + public Converter> fireflyJwtAuthenticationConverter( + PrincipalFactory principalFactory) { + return fireflyJwtAuthenticationConverter(principalFactory, new ResourceServerProperties()); } @Bean diff --git a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java index 38fbbc3f0..e21ea5d35 100644 --- a/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java +++ b/modules/security-resource-server/src/main/java/org/fireflyframework/security/rs/ResourceServerProperties.java @@ -56,6 +56,13 @@ public class ResourceServerProperties { /** Claim paths inspected for OAuth2 scopes. */ private List scopeClaimPaths = new ArrayList<>(); + /** Signed claim dot-paths projected into the principal tenant; empty preserves single-tenant behavior. */ + private List tenantClaimPaths = new ArrayList<>(); + + /** Required unresolved or ambiguous tenant claims deny authentication; optional projection yields null. */ + private boolean tenantRequired; + + /** * Remote JWKS endpoint of an external IdP (e.g. Keycloak), e.g. * {@code https://auth.example/realms/r/protocol/openid-connect/certs}. When set, the default diff --git a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantPrincipalProjectionTest.java b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantPrincipalProjectionTest.java new file mode 100644 index 000000000..20033d52b --- /dev/null +++ b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantPrincipalProjectionTest.java @@ -0,0 +1,40 @@ +package org.fireflyframework.security.rs; + +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.security.core.authority.*; +import org.fireflyframework.security.core.context.PrincipalFactory; +import org.fireflyframework.security.core.tenant.ConfigurableTenantResolver; +import org.fireflyframework.security.webflux.authentication.FireflyAuthenticationToken; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.jwt.Jwt; +import reactor.core.publisher.Mono; +import java.time.Instant; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +class TenantPrincipalProjectionTest { + private final PrincipalFactory factory=new PrincipalFactory(new ConfigurableAuthorityMapper(new AuthorityMappingProperties(List.of("roles"),List.of("scope"),""))); + private Jwt jwt(Map claims){return Jwt.withTokenValue("prevalidated-unit-value").header("alg","RS256").subject("verified-subject").issuer("https://issuer.test").issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(120)).claims(value->value.putAll(claims)).build();} + private SecurityPrincipal principal(JwtToFireflyPrincipalConverter converter,Map claims){return ((FireflyAuthenticationToken)converter.convert(jwt(claims)).block()).securityPrincipal();} + @Test void legacyConstructorRetainsUnconfiguredTenantAndFactorySignature() { + var props=new ResourceServerProperties();assertThat(props.getTenantClaimPaths()).isEmpty();assertThat(props.isTenantRequired()).isFalse(); + assertThat(principal(new JwtToFireflyPrincipalConverter(factory),Map.of("tenant_id","a")).tenantId()).isNull();assertThat(new ResourceServerAutoConfiguration().fireflyJwtAuthenticationConverter(factory)).isNotNull(); + } + @Test void explicitProjectionPreservesEveryOtherPrincipalField() { + var claims=Map.of("org",Map.of("tenant",List.of("a")),"roles",List.of("operator"),"scope","read write","auth_time",Instant.now().minusSeconds(30).getEpochSecond(),"acr","reviewed","amr",List.of("pwd"),"principal_kind","MACHINE","client_id","real-client"); + var converter=new JwtToFireflyPrincipalConverter(factory,new ConfigurableTenantResolver(List.of("org.tenant")),true);var token=jwt(claims);var projected=((FireflyAuthenticationToken)converter.convert(token).block()).securityPrincipal();var original=factory.fromClaims(token.getSubject(),token.getIssuer().toString(),token.getClaims());assertThat(projected).isEqualTo(original.toBuilder().tenantId("a").build());assertThat(projected.claims()).doesNotContainKey("staff_actor_id"); + } + @Test void optionalMissingOrAmbiguousProjectionClearsTenantWithoutCrossRequestState() { + var converter=new JwtToFireflyPrincipalConverter(factory,new ConfigurableTenantResolver(List.of("tenant_ids")),false); + assertThat(principal(converter,Map.of("tenant_ids",List.of("first"))).tenantId()).isEqualTo("first");assertThat(principal(converter,Map.of()).tenantId()).isNull();assertThat(principal(converter,Map.of("tenant_ids",List.of("a","b"))).tenantId()).isNull();assertThat(principal(converter,Map.of("tenant_ids",List.of("last"))).tenantId()).isEqualTo("last"); + } + @Test void requiredMissingOrAmbiguousProjectionDeniesWithoutClaimDisclosure() { + var converter=new JwtToFireflyPrincipalConverter(factory,new ConfigurableTenantResolver(List.of("tenant_ids")),true); + for(var claims:List.of(Map.of(),Map.of("tenant_ids",List.of("private-tenant-a","private-tenant-b"))))assertThatThrownBy(()->principal(converter,claims)).isInstanceOf(OAuth2AuthenticationException.class).hasMessageNotContaining("private-tenant"); + } + @Test void resolverErrorsDenyEvenOptionalProjectionWithoutCauseDisclosure() { + var converter=new JwtToFireflyPrincipalConverter(factory,p->Mono.error(new IllegalStateException("private-claim-value")),false); + assertThatThrownBy(()->principal(converter,Map.of())).isInstanceOf(OAuth2AuthenticationException.class).hasMessageNotContaining("private-claim-value").hasNoCause(); + } +} diff --git a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantProjectionConfigurationTest.java b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantProjectionConfigurationTest.java new file mode 100644 index 000000000..122e034a8 --- /dev/null +++ b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantProjectionConfigurationTest.java @@ -0,0 +1,37 @@ +package org.fireflyframework.security.rs; + +import org.fireflyframework.security.core.context.PrincipalFactory; +import org.fireflyframework.security.webflux.authentication.FireflyAuthenticationToken; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.*; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.jwt.*; +import reactor.core.publisher.Mono; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +class TenantProjectionConfigurationTest { + private final ReactiveWebApplicationContextRunner runner=new ReactiveWebApplicationContextRunner().withConfiguration(AutoConfigurations.of(ResourceServerAutoConfiguration.class)).withUserConfiguration(Decoder.class); + @Test void configuredPropertiesBindToTheSingleNativeConverter() { + runner.withPropertyValues("firefly.security.resource-server.tenant-claim-paths[0]=tenant_id","firefly.security.resource-server.tenant-required=true").run(context->{ + assertThat(context).hasNotFailed();assertThat(context.getBeansOfType(Converter.class).values().stream().filter(JwtToFireflyPrincipalConverter.class::isInstance)).hasSize(1);var props=context.getBean(ResourceServerProperties.class);assertThat(props.getTenantClaimPaths()).containsExactly("tenant_id");assertThat(props.isTenantRequired()).isTrue();var converter=context.getBean(JwtToFireflyPrincipalConverter.class);var jwt=Jwt.withTokenValue("prevalidated-unit-value").header("alg","RS256").subject("subject").claim("tenant_id","configured-tenant").build();assertThat(((FireflyAuthenticationToken)converter.convert(jwt).block()).securityPrincipal().tenantId()).isEqualTo("configured-tenant"); + }); + } + @Test void requiredTenantWithoutValidConfiguredPathsFailsStartup() { + for(var paths:List.of(List.of(),List.of(" "),List.of("org..tenant"),List.of("org.tenant."))) { + var props=new ResourceServerProperties();props.setTenantRequired(true);props.setTenantClaimPaths(paths);assertThatThrownBy(()->new ResourceServerAutoConfiguration().fireflyJwtAuthenticationConverter(new PrincipalFactory(new org.fireflyframework.security.core.authority.ConfigurableAuthorityMapper(new org.fireflyframework.security.core.authority.AuthorityMappingProperties(List.of(),List.of(),""))),props)).isInstanceOf(IllegalArgumentException.class); + } + runner.withPropertyValues("firefly.security.resource-server.tenant-required=true").run(context->assertThat(context).hasFailed()); + } + @Test void projectConverterStillOverridesAutoConfiguration() { + runner.withUserConfiguration(CustomConverter.class).withPropertyValues("firefly.security.resource-server.tenant-required=true").run(context->{assertThat(context).hasNotFailed();assertThat(context.getBean("customJwtConverter")).isSameAs(CustomConverter.CONVERTER);assertThat(context).doesNotHaveBean("fireflyJwtAuthenticationConverter");}); + } + @Configuration(proxyBeanMethods=false) static class Decoder {@Bean ReactiveJwtDecoder syntheticDecoder(){return token->Mono.error(new BadJwtException("No decoder call is expected in binding tests"));}} + @Configuration(proxyBeanMethods=false) static class CustomConverter { + static final Converter> CONVERTER=jwt->Mono.error(new BadJwtException("Project converter placeholder for bean-override contract only")); + @Bean Converter> customJwtConverter(){return CONVERTER;} + } +} diff --git a/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantProjectionHttpIntegrationTest.java b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantProjectionHttpIntegrationTest.java new file mode 100644 index 000000000..9d8fe9853 --- /dev/null +++ b/modules/security-resource-server/src/test/java/org/fireflyframework/security/rs/TenantProjectionHttpIntegrationTest.java @@ -0,0 +1,60 @@ +package org.fireflyframework.security.rs; + +import com.nimbusds.jose.*; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.*; +import org.fireflyframework.security.api.domain.*; +import org.fireflyframework.security.core.policy.PolicyRule; +import org.fireflyframework.security.spi.KeyManagementPort; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; +import java.net.URI; +import java.net.http.*; +import java.security.interfaces.RSAPrivateKey; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes=TenantProjectionHttpIntegrationTest.Application.class, + webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT, + properties={"server.address=127.0.0.1","firefly.security.resource-server.strict-policy=true", + "firefly.security.resource-server.tenant-claim-paths[0]=tenant_ids", + "firefly.security.resource-server.tenant-required=true", + "firefly.security.resource-server.issuer=https://issuer.test", + "firefly.security.resource-server.audiences=tenant-projection-tests"}) +class TenantProjectionHttpIntegrationTest { + static final AtomicBoolean CURRENT_PERMISSION=new AtomicBoolean(true); + static final AtomicInteger EFFECTS=new AtomicInteger(); + @Autowired KeyManagementPort keys; + @LocalServerPort int port; + @BeforeEach void reset(){CURRENT_PERMISSION.set(true);EFFECTS.set(0);} + @SpringBootConfiguration @EnableAutoConfiguration + static class Application { + @Bean PolicyRule currentTenantPolicy(){return (principal,action,resource,context)->Mono.just(CURRENT_PERMISSION.get() && "tenant-a".equals(principal.tenantId())?Decision.permit():Decision.deny("Current tenant permission required"));} + @RestController static class Controller { + @GetMapping("/tenant-projection") String read(@AuthenticationPrincipal SecurityPrincipal principal){EFFECTS.incrementAndGet();return principal.tenantId()+":"+principal.claims().get("principal_kind");} + } + } + @Test void signatureValidatedTenantReachesActualPrincipalAndCurrentPolicy() throws Exception { + var response=send(token(List.of("tenant-a"),"tenant-projection-tests"));assertEquals(200,response.statusCode(),response.body());assertEquals("tenant-a:MACHINE",response.body());assertEquals(1,EFFECTS.get()); + } + @Test void requiredMissingAmbiguousAndWrongAudienceTokensDoNotReachController() throws Exception { + assertEquals(401,send(token(null,"tenant-projection-tests")).statusCode());assertEquals(401,send(token(List.of("tenant-a","tenant-b"),"tenant-projection-tests")).statusCode());assertEquals(401,send(token(List.of("tenant-a"),"wrong-audience")).statusCode());assertEquals(0,EFFECTS.get()); + } + @Test void resolvedTenantDoesNotBypassCurrentPermissionOrOtherTenantPolicy() throws Exception { + var token=token(List.of("tenant-a"),"tenant-projection-tests");CURRENT_PERMISSION.set(false);assertEquals(403,send(token).statusCode());CURRENT_PERMISSION.set(true);assertEquals(403,send(token(List.of("tenant-b"),"tenant-projection-tests")).statusCode());assertEquals(0,EFFECTS.get());assertEquals(200,send(token).statusCode());assertEquals(1,EFFECTS.get()); + } + String token(Object tenant,String audience) throws Exception { + var claims=new JWTClaimsSet.Builder().issuer("https://issuer.test").subject("signed-machine").audience(audience).expirationTime(Date.from(Instant.now().plusSeconds(120))).claim("principal_kind","MACHINE").claim("client_id","integration-client");if(tenant!=null)claims.claim("tenant_ids",tenant);var key=keys.activeSigningKey().block();assertNotNull(key);var token=new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.kid()).build(),claims.build());token.sign(new RSASSASigner((RSAPrivateKey)key.privateKey()));return token.serialize(); + } + HttpResponse send(String token) throws Exception {return HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/tenant-projection")).header("Authorization","Bearer "+token).GET().build(),HttpResponse.BodyHandlers.ofString());} +} From 134f4fb1113226fe45766cbe50ae90ade843b5df Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sat, 24 Oct 2026 03:49:41 -0700 Subject: [PATCH 13/44] Provide opt-in UTC auditing for mixed modern temporal models --- modules/r2dbc/README.md | 4 ++ .../auditing/UtcOffsetDateTimeProvider.java | 26 ++++++++ .../UtcOffsetDateTimeProviderTest.java | 60 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/auditing/UtcOffsetDateTimeProvider.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/auditing/UtcOffsetDateTimeProviderTest.java diff --git a/modules/r2dbc/README.md b/modules/r2dbc/README.md index c3c8686ed..98ed28736 100644 --- a/modules/r2dbc/README.md +++ b/modules/r2dbc/README.md @@ -42,6 +42,10 @@ spring: password: secret ``` +### Optional UTC auditing + +For `LocalDateTime`, `OffsetDateTime`, and `Instant` audit fields, expose `new org.fireflyframework.core.auditing.UtcOffsetDateTimeProvider(Clock.systemUTC())` as a `DateTimeProvider` bean and select its name with `@EnableR2dbcAuditing(dateTimeProviderRef = "yourBeanName")`. The injected clock is normalized to UTC. Spring Data retains create/update, immutable entity, and auditing flag behavior. This helper is opt-in; it does not enable auditing or register a bean automatically. Legacy `Date` and `Long` audit fields are unsupported by this provider. + ### 2. Define an entity, a filter DTO, and a paginated endpoint ```java diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/auditing/UtcOffsetDateTimeProvider.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/auditing/UtcOffsetDateTimeProvider.java new file mode 100644 index 000000000..f87294327 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/auditing/UtcOffsetDateTimeProvider.java @@ -0,0 +1,26 @@ +package org.fireflyframework.core.auditing; + +import org.springframework.data.auditing.DateTimeProvider; +import java.time.Clock; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.TemporalAccessor; +import java.util.Objects; +import java.util.Optional; + +/** + * Opt-in UTC time source for Spring Data auditing of LocalDateTime, + * OffsetDateTime, and Instant fields. Date and Long fields are unsupported. + */ +public final class UtcOffsetDateTimeProvider implements DateTimeProvider { + private final Clock clock; + + public UtcOffsetDateTimeProvider(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock").withZone(ZoneOffset.UTC); + } + + @Override + public Optional getNow() { + return Optional.of(OffsetDateTime.now(clock)); + } +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/auditing/UtcOffsetDateTimeProviderTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/auditing/UtcOffsetDateTimeProviderTest.java new file mode 100644 index 000000000..51ee47b31 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/auditing/UtcOffsetDateTimeProviderTest.java @@ -0,0 +1,60 @@ +package org.fireflyframework.core.auditing; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; +import org.springframework.data.annotation.*; +import org.springframework.data.auditing.ReactiveIsNewAwareAuditingHandler; +import org.springframework.data.mapping.context.PersistentEntities; +import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; +import org.springframework.data.r2dbc.mapping.R2dbcSimpleTypeHolder; +import java.time.*; +import java.util.Date; +import java.util.TimeZone; +import static org.junit.jupiter.api.Assertions.*; + +@Isolated +class UtcOffsetDateTimeProviderTest { + private static final Instant NOW=Instant.parse("2026-01-01T12:34:56Z"); + static class AuditBase { @Id String id; @Version Long version; @CreatedDate OffsetDateTime created; @LastModifiedDate OffsetDateTime modified; } + static class InheritedAudit extends AuditBase { String value; } + static class LocalAudit { @Id String id; @Version Long version; @CreatedDate LocalDateTime created; @LastModifiedDate LocalDateTime modified; } + static class InstantAudit { @Id String id; @Version Long version; @CreatedDate Instant created; @LastModifiedDate Instant modified; } + static class DateAudit { @Id String id; @CreatedDate Date created; } + static class LongAudit { @Id String id; @CreatedDate Long created; } + record ImmutableAudit(@Id String id,@Version Long version,@CreatedDate OffsetDateTime created,@LastModifiedDate OffsetDateTime modified) {} + static ReactiveIsNewAwareAuditingHandler handler() { + var context=new R2dbcMappingContext();context.setSimpleTypeHolder(R2dbcSimpleTypeHolder.HOLDER); + for(var type:new Class[]{InheritedAudit.class,LocalAudit.class,InstantAudit.class,DateAudit.class,LongAudit.class,ImmutableAudit.class})context.getRequiredPersistentEntity(type); + var result=new ReactiveIsNewAwareAuditingHandler(PersistentEntities.of(context));result.setDateTimeProvider(new UtcOffsetDateTimeProvider(Clock.fixed(NOW,ZoneId.of("Asia/Tokyo"))));return result; + } + @Test void ordinaryInfrastructureDoesNotAutoRegisterOrEnableProvider() { + new org.springframework.boot.test.context.runner.ApplicationContextRunner() + .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(org.fireflyframework.core.config.R2dbcAutoConfiguration.class)) + .withBean(org.springframework.data.r2dbc.core.R2dbcEntityTemplate.class,()->org.mockito.Mockito.mock(org.springframework.data.r2dbc.core.R2dbcEntityTemplate.class)) + .run(app->{assertNull(app.getStartupFailure());assertTrue(app.getBeansOfType(org.springframework.data.auditing.DateTimeProvider.class).isEmpty());assertTrue(app.getBeansOfType(org.springframework.data.auditing.ReactiveAuditingHandler.class).isEmpty());}); + } + @Test void injectedClockIsNormalizedToUtcAndNullIsRejected() { + var provider=new UtcOffsetDateTimeProvider(Clock.fixed(NOW,ZoneId.of("Asia/Tokyo"))); + assertEquals(OffsetDateTime.ofInstant(NOW,ZoneOffset.UTC),provider.getNow().orElseThrow());assertThrows(NullPointerException.class,()->new UtcOffsetDateTimeProvider(null)); + } + @Test void inheritedFieldsUseStandardCreateAndUpdateAndIsNewDetection() { + var handler=handler();var entity=new InheritedAudit();var created=(InheritedAudit)handler.markAudited(entity).block();assertSame(entity,created);assertEquals(NOW,created.created.toInstant());assertEquals(created.created,created.modified); + entity.version=3L;handler.setDateTimeProvider(new UtcOffsetDateTimeProvider(Clock.fixed(NOW.plusSeconds(5),ZoneOffset.UTC)));var updated=(InheritedAudit)handler.markAudited(entity).block();assertEquals(NOW,updated.created.toInstant());assertEquals(NOW.plusSeconds(5),updated.modified.toInstant()); + } + @Test void standardMappingPreservesImmutableCreateAndUpdateCopies() { + var handler=handler();var original=new ImmutableAudit(null,null,null,null);var created=(ImmutableAudit)handler.markAudited(original).block();assertNotSame(original,created);assertNull(original.created());assertEquals(NOW,created.created().toInstant()); + handler.setDateTimeProvider(new UtcOffsetDateTimeProvider(Clock.fixed(NOW.plusSeconds(5),ZoneOffset.UTC)));var updated=(ImmutableAudit)handler.markAudited(new ImmutableAudit("present",1L,created.created(),created.modified())).block();assertEquals(created.created(),updated.created());assertEquals(NOW.plusSeconds(5),updated.modified().toInstant()); + } + @Test void localOffsetAndInstantFieldsRemainUtcInNonUtcJvm() { + var previous=TimeZone.getDefault();try {TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"));var handler=handler();var local=(LocalAudit)handler.markAudited(new LocalAudit()).block();var offset=(InheritedAudit)handler.markAudited(new InheritedAudit()).block();var instant=(InstantAudit)handler.markAudited(new InstantAudit()).block();assertEquals(LocalDateTime.ofInstant(NOW,ZoneOffset.UTC),local.created);assertEquals(ZoneOffset.UTC,offset.created.getOffset());assertEquals(NOW,offset.created.toInstant());assertEquals(NOW,instant.created);}finally{TimeZone.setDefault(previous);} + } + @Test void modifyOnCreationFalseKeepsStandardFlagBehavior() { + var handler=handler();handler.setModifyOnCreation(false);var entity=(InheritedAudit)handler.markAudited(new InheritedAudit()).block();assertNotNull(entity.created);assertNull(entity.modified); + } + @Test void dateTimeForNowFalseKeepsStandardFlagBehavior() { + var handler=handler();handler.setDateTimeForNow(false);var entity=(InheritedAudit)handler.markAudited(new InheritedAudit()).block();assertNull(entity.created);assertNull(entity.modified); + } + @Test void unsupportedLegacyDateAndLongRemainExplicitlyUnsupported() { + var handler=handler();assertThrows(IllegalArgumentException.class,()->handler.markAudited(new DateAudit()).block());assertThrows(IllegalArgumentException.class,()->handler.markAudited(new LongAudit()).block()); + } +} From ef2a5e6345be99033e93e3af8c33cd6fdfa7d070 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sat, 24 Oct 2026 04:48:48 -0700 Subject: [PATCH 14/44] Keep database driver details out of generic error responses --- .../DataAccessExceptionConverter.java | 4 +- .../DataAccessResponsePrivacyTest.java | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 modules/web/src/test/java/org/fireflyframework/web/error/handler/DataAccessResponsePrivacyTest.java diff --git a/modules/web/src/main/java/org/fireflyframework/web/error/converter/DataAccessExceptionConverter.java b/modules/web/src/main/java/org/fireflyframework/web/error/converter/DataAccessExceptionConverter.java index 16cd37a61..fcd6c308c 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/error/converter/DataAccessExceptionConverter.java +++ b/modules/web/src/main/java/org/fireflyframework/web/error/converter/DataAccessExceptionConverter.java @@ -62,7 +62,7 @@ public BusinessException convert(DataAccessException exception) { } else { return new ServiceException( "DATABASE_ERROR", - "A database error occurred: " + exception.getMessage() + "A database error occurred." ); } } @@ -168,7 +168,7 @@ private BusinessException handleQueryTimeoutException(QueryTimeoutException exce private BusinessException handleTransientDataAccessException(TransientDataAccessException exception) { return new ServiceException( "TRANSIENT_DATABASE_ERROR", - "A temporary database error occurred: " + exception.getMessage() + "A temporary database error occurred." ); } } diff --git a/modules/web/src/test/java/org/fireflyframework/web/error/handler/DataAccessResponsePrivacyTest.java b/modules/web/src/test/java/org/fireflyframework/web/error/handler/DataAccessResponsePrivacyTest.java new file mode 100644 index 000000000..88c98fea4 --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/error/handler/DataAccessResponsePrivacyTest.java @@ -0,0 +1,70 @@ +package org.fireflyframework.web.error.handler; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.fireflyframework.web.error.config.ErrorHandlingProperties; +import org.fireflyframework.web.error.converter.DataAccessExceptionConverter; +import org.fireflyframework.web.error.converter.ExceptionConverterService; +import org.fireflyframework.web.error.service.ErrorResponseNegotiator; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.InvalidDataAccessResourceUsageException; +import org.springframework.dao.TransientDataAccessResourceException; +import org.springframework.http.HttpStatus; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +class DataAccessResponsePrivacyTest { + private static final String DRIVER_MESSAGE = + "executeMany; bad SQL grammar [INSERT INTO private_table (private_column)" + + " VALUES ('synthetic-private-database-value')]"; + + static Stream responses() { + return Stream.of("default", "development", "prod", "production") + .flatMap(profile -> Stream.of("application/json", "application/problem+json") + .flatMap(accept -> Stream.of( + Arguments.of(profile, accept, + new InvalidDataAccessResourceUsageException(DRIVER_MESSAGE), + "DATABASE_ERROR", "A database error occurred."), + Arguments.of(profile, accept, + new TransientDataAccessResourceException(DRIVER_MESSAGE), + "TRANSIENT_DATABASE_ERROR", "A temporary database error occurred.")))); + } + + @ParameterizedTest + @MethodSource("responses") + void genericDatabaseResponsesKeepTheirStatusAndCodeWithoutDriverDetails( + String profile, String accept, DataAccessException exception, + String code, String message) throws Exception { + var mapper = new ObjectMapper().findAndRegisterModules(); + var properties = new ErrorHandlingProperties(); + properties.setIncludeDebugInfo(true); + properties.setIncludeExceptionCause(true); + var environment = new MockEnvironment(); + environment.setActiveProfiles(profile); + var converters = new ExceptionConverterService(List.of(new DataAccessExceptionConverter())); + var handler = new GlobalExceptionHandler(converters, Optional.empty(), properties, + Optional.empty(), Optional.empty(), environment, mapper, + new ErrorResponseNegotiator(mapper), Optional.empty()); + var exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/api/assets") + .header("Accept", accept).build()); + + handler.handle(exchange, exception).block(); + + String body = exchange.getResponse().getBodyAsString().block(); + var json = mapper.readTree(body); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(body).contains(code).doesNotContain( + "private_table", "private_column", "synthetic-private-database-value", "INSERT INTO"); + assertThat(json.path(accept.equals("application/json") ? "message" : "detail").asText()) + .isEqualTo(message); + assertThat(exception.getMessage()).isEqualTo(DRIVER_MESSAGE); + } +} From 5296bca4dea8cb16ac209b9280d8398174a92187 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sat, 24 Oct 2026 14:32:32 -0700 Subject: [PATCH 15/44] Preserve generated AES key material across configuration and rotation --- docs/site/modules/client.md | 4 +- modules/client/README.md | 4 +- .../security/SecretsEncryptionManager.java | 18 +-- ...cretsEncryptionManagerKeyEncodingTest.java | 129 ++++++++++++++++++ 4 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 modules/client/src/test/java/org/fireflyframework/client/security/SecretsEncryptionManagerKeyEncodingTest.java diff --git a/docs/site/modules/client.md b/docs/site/modules/client.md index 92ed31a84..45e4eefeb 100644 --- a/docs/site/modules/client.md +++ b/docs/site/modules/client.md @@ -32,7 +32,9 @@ The service may call a document-verification partner during a later onboarding s Secret storage, missing-secret, and removal events use fixed messages without caller-supplied secret names. Successful JWT validation does not log the verified subject. A non-null subject must remain text; other shapes receive a constant `JwtValidationException` diagnostic. Missing/null subjects and the existing required-claim presence rule retain their behavior. Encryption checks the combined IV and ciphertext length before allocating its output buffer; this check does not impose a new plaintext or configured-IV size budget. The cipher format and validation APIs are unchanged. -The generated-key encoding contract remains **open**: `create()` converts random bytes through ISO-8859-1 before UTF-8 key loading, while `generateMasterKey()` returns Base64 and `rotateKey(String)` expects raw 32-byte UTF-8 text. These factory/generated-key paths are not qualified by this correction. Cipher compatibility tests use an explicitly supplied 32-byte ASCII master key; production key material belongs in an application-managed secret source. +The master key accepts 32 UTF-8 bytes or the standard Base64 encoding of 32 key bytes. Existing 32-byte UTF-8 keys retain their raw interpretation. `generateMasterKey()` returns a Base64 key that can be supplied to the builder or `rotateKey(String)`; `create()` uses a generated key directly. The AES-GCM IV/ciphertext format is preserved. Keep the configured key in an application-managed secret source when ciphertext must outlive a manager instance. + +This encoding correction is part of the current source checkpoint. Consumer adoption requires a coordinated follow-up release; previously published artifacts are not replaced under the same version. ## Quick start diff --git a/modules/client/README.md b/modules/client/README.md index dcecb9205..e14108738 100644 --- a/modules/client/README.md +++ b/modules/client/README.md @@ -28,7 +28,9 @@ The service may call a document-verification partner during a later onboarding s Secret storage, missing-secret, and removal events use fixed messages without caller-supplied secret names. Successful JWT validation does not log the verified subject. A non-null subject must remain text; other shapes receive a constant `JwtValidationException` diagnostic. Missing/null subjects and the existing required-claim presence rule retain their behavior. Encryption checks the combined IV and ciphertext length before allocating its output buffer; this check does not impose a new plaintext or configured-IV size budget. The cipher format and validation APIs are unchanged. -The generated-key encoding contract remains **open**: `create()` converts random bytes through ISO-8859-1 before UTF-8 key loading, while `generateMasterKey()` returns Base64 and `rotateKey(String)` expects raw 32-byte UTF-8 text. These factory/generated-key paths are not qualified by this correction. Cipher compatibility tests use an explicitly supplied 32-byte ASCII master key; production key material belongs in an application-managed secret source. +The master key accepts 32 UTF-8 bytes or the standard Base64 encoding of 32 key bytes. Existing 32-byte UTF-8 keys retain their raw interpretation. `generateMasterKey()` returns a Base64 key that can be supplied to the builder or `rotateKey(String)`; `create()` uses a generated key directly. The AES-GCM IV/ciphertext format is preserved. Keep the configured key in an application-managed secret source when ciphertext must outlive a manager instance. + +This encoding correction is part of the current source checkpoint. Consumer adoption requires a coordinated follow-up release; previously published artifacts are not replaced under the same version. ## Quick start diff --git a/modules/client/src/main/java/org/fireflyframework/client/security/SecretsEncryptionManager.java b/modules/client/src/main/java/org/fireflyframework/client/security/SecretsEncryptionManager.java index 13bcf3fa6..74bfa27b2 100644 --- a/modules/client/src/main/java/org/fireflyframework/client/security/SecretsEncryptionManager.java +++ b/modules/client/src/main/java/org/fireflyframework/client/security/SecretsEncryptionManager.java @@ -48,7 +48,7 @@ public class SecretsEncryptionManager { /** - * Master encryption key (32 bytes for AES-256). + * Master encryption key: 32 UTF-8 bytes or their Base64 encoding for AES-256. */ private final String masterKey; @@ -284,8 +284,15 @@ private SecretKey getSecretKey() { throw new IllegalStateException("Master key is not configured"); } - // Ensure master key is exactly 32 bytes for AES-256 + // Prefer legacy UTF-8 keys so existing configurations keep their meaning. byte[] keyBytes = masterKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 32) { + try { + keyBytes = Base64.getDecoder().decode(masterKey); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Master key must be exactly 32 bytes for AES-256"); + } + } if (keyBytes.length != 32) { throw new IllegalStateException("Master key must be exactly 32 bytes for AES-256"); } @@ -315,13 +322,8 @@ public static String generateMasterKey() { * @return configured SecretsEncryptionManager */ public static SecretsEncryptionManager create() { - String masterKey = generateMasterKey(); - // Decode to get exactly 32 bytes - byte[] keyBytes = Base64.getDecoder().decode(masterKey); - String key32Bytes = new String(keyBytes, StandardCharsets.ISO_8859_1); - return SecretsEncryptionManager.builder() - .masterKey(key32Bytes) + .masterKey(generateMasterKey()) .build(); } } diff --git a/modules/client/src/test/java/org/fireflyframework/client/security/SecretsEncryptionManagerKeyEncodingTest.java b/modules/client/src/test/java/org/fireflyframework/client/security/SecretsEncryptionManagerKeyEncodingTest.java new file mode 100644 index 000000000..3ef6c6e8e --- /dev/null +++ b/modules/client/src/test/java/org/fireflyframework/client/security/SecretsEncryptionManagerKeyEncodingTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.client.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 org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.UUID; +import java.util.stream.Stream; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +class SecretsEncryptionManagerKeyEncodingTest { + @Test + void generatedFactoryKeyEncryptsAndDecryptsUnicode() { + assertThatCode(() -> roundTrip(SecretsEncryptionManager.create())).doesNotThrowAnyException(); + } + + @Test + void generatedBase64KeyCanConfigureThePublicBuilder() { + String generated = SecretsEncryptionManager.generateMasterKey(); + assertThat(Base64.getDecoder().decode(generated)).hasSize(32); + assertThatCode(() -> roundTrip(manager(generated))).doesNotThrowAnyException(); + } + + @Test + void rotationAcceptsAGeneratedKeyAndPreservesBothManagers() { + var original = manager(rawAsciiKey()); + String label = "synthetic-rotation-label"; + String value = "Synthetic rotation value Ω"; + original.storeSecret(label, value); + assertThatCode(() -> { + var rotated = original.rotateKey(SecretsEncryptionManager.generateMasterKey()); + assertThat(rotated.getSecret(label)).isEqualTo(value); + assertThat(original.getSecret(label)).isEqualTo(value); + assertThat(rotated.getSecretCount()).isEqualTo(1); + }).doesNotThrowAnyException(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("rawRepresentations") + void preservesExistingThirtyTwoByteUtf8Keys(String description, String key) { + assertThat(key.getBytes(StandardCharsets.UTF_8)).hasSize(32); + roundTrip(manager(key)); + } + + static Stream rawRepresentations() { + var random = new SecureRandom(); + char[] unicode = new char[16]; + for (int i = 0; i < unicode.length; i++) { + unicode[i] = (char) (0x100 + random.nextInt(0x80)); + } + return Stream.of(Arguments.of("raw ASCII", rawAsciiKey()), + Arguments.of("raw multibyte UTF-8", new String(unicode))); + } + + @Test + void decryptsTheExistingIvAndCiphertextFrameWithARawKey() throws Exception { + String key = rawAsciiKey(); + String plaintext = "Synthetic existing frame Ω"; + byte[] iv = new byte[12]; + new SecureRandom().nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"), + new GCMParameterSpec(128, iv)); + var frame = new ByteArrayOutputStream(); + frame.writeBytes(iv); + frame.writeBytes(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8))); + assertThat(manager(key).decrypt(Base64.getEncoder().encodeToString(frame.toByteArray()))) + .isEqualTo(plaintext); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidConfigurations") + void rejectsUnsupportedKeyMaterial(String description, String key) { + assertThatThrownBy(() -> manager(key).encrypt("synthetic plaintext")) + .isInstanceOf(RuntimeException.class) + .hasMessage("Encryption failed") + .hasCauseInstanceOf(IllegalStateException.class); + } + + static Stream invalidConfigurations() { + byte[] shortMaterial = new byte[16]; + new SecureRandom().nextBytes(shortMaterial); + return Stream.of(Arguments.of("missing", (String) null), + Arguments.of("empty", ""), + Arguments.of("Base64 of non-AES256 material", Base64.getEncoder().encodeToString(shortMaterial)), + Arguments.of("invalid text", "!" + UUID.randomUUID())); + } + + private static void roundTrip(SecretsEncryptionManager manager) { + String value = "Synthetic generated-key value Ω 🚀"; + assertThat(manager.decrypt(manager.encrypt(value))).isEqualTo(value); + } + + private static SecretsEncryptionManager manager(String key) { + return SecretsEncryptionManager.builder().masterKey(key).build(); + } + + private static String rawAsciiKey() { + return UUID.randomUUID().toString().replace("-", ""); + } +} From 119b74f1922c6804cb139898a3ec9f81cd162ac6 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 01:26:16 -0700 Subject: [PATCH 16/44] Require literal boolean grants from OPA and OpenFGA --- .../adapter/opa/OpaPolicyDecisionAdapter.java | 2 +- .../OpaPolicyDecisionAdapterProtocolTest.java | 59 +++++++++++++++++++ .../openfga/OpenFgaRelationshipAdapter.java | 2 +- ...penFgaRelationshipAdapterProtocolTest.java | 54 +++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 modules/security-adapter-opa/src/test/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapterProtocolTest.java create mode 100644 modules/security-adapter-openfga/src/test/java/org/fireflyframework/security/adapter/openfga/OpenFgaRelationshipAdapterProtocolTest.java diff --git a/modules/security-adapter-opa/src/main/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapter.java b/modules/security-adapter-opa/src/main/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapter.java index 66c76c83b..337a719ff 100644 --- a/modules/security-adapter-opa/src/main/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapter.java +++ b/modules/security-adapter-opa/src/main/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapter.java @@ -80,6 +80,6 @@ public Mono authorize(SecurityPrincipal principal, String action, Stri /** Subset of the OPA data API response ({@code {"result": }}). */ @JsonIgnoreProperties(ignoreUnknown = true) - record OpaResult(Boolean result) { + record OpaResult(Object result) { } } diff --git a/modules/security-adapter-opa/src/test/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapterProtocolTest.java b/modules/security-adapter-opa/src/test/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapterProtocolTest.java new file mode 100644 index 000000000..4d7b68bc8 --- /dev/null +++ b/modules/security-adapter-opa/src/test/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapterProtocolTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.security.adapter.opa; + +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class OpaPolicyDecisionAdapterProtocolTest { + private static final Duration WAIT = Duration.ofSeconds(3); + private final SecurityPrincipal principal = SecurityPrincipal.builder().subject("protocol-subject").build(); + + private OpaPolicyDecisionAdapter adapter(String body) { + return new OpaPolicyDecisionAdapter(WebClient.builder().baseUrl("https://policy.example.test") + .exchangeFunction(request -> Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json").body(body).build())).build(), "firefly/allow"); + } + + @Test + void literalBooleanTruePermits() { + var decision = adapter("{\"result\":true}").authorize(principal, "read", "resource", Map.of()).block(WAIT); + assertNotNull(decision); + assertTrue(decision.granted()); + } + + @ParameterizedTest + @ValueSource(strings = {"{\"result\":false}", "{\"result\":\"true\"}", "{\"result\":1}", + "{\"result\":{}}", "{\"result\":[]}", "{\"result\":null}", "{}", "{invalid"}) + void nonBooleanTrueAndMalformedResponsesDeny(String response) { + var decision = adapter(response).authorize(principal, "read", "resource", Map.of()).block(WAIT); + assertNotNull(decision); + assertFalse(decision.granted()); + } + + @Test + void unavailableTransportDenies() { + var client = WebClient.builder().baseUrl("https://policy.example.test") + .exchangeFunction(request -> Mono.error(new IOException("Owned protocol transport unavailable"))).build(); + var decision = new OpaPolicyDecisionAdapter(client, "firefly/allow") + .authorize(principal, "read", "resource", Map.of()).block(WAIT); + assertNotNull(decision); + assertFalse(decision.granted()); + } +} diff --git a/modules/security-adapter-openfga/src/main/java/org/fireflyframework/security/adapter/openfga/OpenFgaRelationshipAdapter.java b/modules/security-adapter-openfga/src/main/java/org/fireflyframework/security/adapter/openfga/OpenFgaRelationshipAdapter.java index 85b73858a..7c4ff53ef 100644 --- a/modules/security-adapter-openfga/src/main/java/org/fireflyframework/security/adapter/openfga/OpenFgaRelationshipAdapter.java +++ b/modules/security-adapter-openfga/src/main/java/org/fireflyframework/security/adapter/openfga/OpenFgaRelationshipAdapter.java @@ -65,6 +65,6 @@ public Mono check(String subject, String relation, String object) { /** Subset of the OpenFGA check response ({@code {"allowed": }}). */ @JsonIgnoreProperties(ignoreUnknown = true) - record CheckResult(Boolean allowed) { + record CheckResult(Object allowed) { } } diff --git a/modules/security-adapter-openfga/src/test/java/org/fireflyframework/security/adapter/openfga/OpenFgaRelationshipAdapterProtocolTest.java b/modules/security-adapter-openfga/src/test/java/org/fireflyframework/security/adapter/openfga/OpenFgaRelationshipAdapterProtocolTest.java new file mode 100644 index 000000000..65c977a21 --- /dev/null +++ b/modules/security-adapter-openfga/src/test/java/org/fireflyframework/security/adapter/openfga/OpenFgaRelationshipAdapterProtocolTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.security.adapter.openfga; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import java.io.IOException; +import java.time.Duration; +import static org.junit.jupiter.api.Assertions.*; + +class OpenFgaRelationshipAdapterProtocolTest { + private static final Duration WAIT = Duration.ofSeconds(3); + + private OpenFgaRelationshipAdapter adapter(String body) { + return new OpenFgaRelationshipAdapter(WebClient.builder().baseUrl("https://policy.example.test") + .exchangeFunction(request -> Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json").body(body).build())).build(), "protocol-store"); + } + + @Test + void literalBooleanTruePermits() { + var allowed = adapter("{\"allowed\":true}").check("subject", "member", "resource").block(WAIT); + assertNotNull(allowed); + assertTrue(allowed); + } + + @ParameterizedTest + @ValueSource(strings = {"{\"allowed\":false}", "{\"allowed\":\"true\"}", "{\"allowed\":1}", + "{\"allowed\":{}}", "{\"allowed\":[]}", "{\"allowed\":null}", "{}", "{invalid"}) + void nonBooleanTrueAndMalformedResponsesDeny(String response) { + var allowed = adapter(response).check("subject", "member", "resource").block(WAIT); + assertNotNull(allowed); + assertFalse(allowed); + } + + @Test + void unavailableTransportDenies() { + var client = WebClient.builder().baseUrl("https://policy.example.test") + .exchangeFunction(request -> Mono.error(new IOException("Owned protocol transport unavailable"))).build(); + var allowed = new OpenFgaRelationshipAdapter(client, "protocol-store") + .check("subject", "member", "resource").block(WAIT); + assertNotNull(allowed); + assertFalse(allowed); + } +} From 2bea932c59447e39d4471ff29d2e0218b631b9f2 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 01:50:05 -0700 Subject: [PATCH 17/44] Preserve typed SDK variants through opt-in generation --- build/parent/README.md | 13 +++++++++++++ build/parent/pom.xml | 8 ++++++++ docs/site/modules/parent.md | 11 +++++++++++ 3 files changed, 32 insertions(+) diff --git a/build/parent/README.md b/build/parent/README.md index a9fa9d07e..b46901163 100644 --- a/build/parent/README.md +++ b/build/parent/README.md @@ -29,6 +29,19 @@ The onboarding example inherits this parent and keeps its own application versio This POM has no runtime configuration namespace. Configure the selected child libraries in the application that uses them. +### Generated Java SDK variants + +The managed OpenAPI Generator plugin exposes two opt-in build properties for object variants described with `oneOf`: + +```xml + + true + true + +``` + +Both properties default to `false`, preserving the existing generator behavior. For the qualified Java WebClient configuration, the interface option generates separate typed variants and the deduction option uses their JSON field shapes during Jackson deserialization. Qualify serialization, deserialization, ambiguous payloads, and compatibility with the owning API before enabling them in a consumer. Keep the API schema and normal generator lifecycle authoritative for SDK output. These properties do not change a service's business admission rules or publish an updated SDK. + ## Continue the application Use the [CQRS guide](../../docs/site/modules/cqrs.md) for the next part of this integration. diff --git a/build/parent/pom.xml b/build/parent/pom.xml index 5d5ef98c8..89ae8acfd 100644 --- a/build/parent/pom.xml +++ b/build/parent/pom.xml @@ -53,6 +53,8 @@ true true java8 + false + false 4.1.1 @@ -1126,6 +1128,12 @@ org.openapitools openapi-generator-maven-plugin ${openapi-generator.version} + + + ${openapi.use.oneof.interfaces} + ${openapi.use.oneof.deduction} + + diff --git a/docs/site/modules/parent.md b/docs/site/modules/parent.md index 446978c62..005d429b6 100644 --- a/docs/site/modules/parent.md +++ b/docs/site/modules/parent.md @@ -70,6 +70,17 @@ Versions below are read directly from the parent POM. Spring Boot supplies addit | `maven-failsafe-plugin.version` | `3.6.0` | | `native-maven-plugin.version` | `1.1.14` | +### Generated Java SDK object variants + +The managed OpenAPI Generator plugin accepts these opt-in Maven properties: + +| Property | Default | Generated behavior | +| --- | --- | --- | +| `openapi.use.oneof.interfaces` | `false` | Generates interfaces for supported `oneOf` object variants. | +| `openapi.use.oneof.deduction` | `false` | Enables Jackson deduction from variant field shapes in the qualified Java WebClient configuration. | + +Set both properties to `true` in the consuming SDK project after qualifying its schema, generator configuration, and runtime mapper. Test each variant's serialized shape and deserialization, including ambiguous objects and compatibility with the owning API. Continue to generate SDK code through the original OpenAPI lifecycle. These options preserve existing behavior by default and do not alter business admission rules or publish consumer artifacts. + ### Imported dependency platforms | BOM | Version expression | From 490cbcef1148ff8fac30ae35593e316fc2d114d2 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 03:53:04 -0700 Subject: [PATCH 18/44] Preserve Instant semantics through opt-in OpenAPI scalar generation --- docs/site/modules/web.md | 37 +++ modules/web/README.md | 14 + .../web/openapi/EnableOpenApiGen.java | 3 +- ...InstantScalarOpenAPIAutoConfiguration.java | 35 +++ .../InstantScalarOpenApiCustomizer.java | 133 +++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...antScalarOpenAPIAutoConfigurationTest.java | 110 ++++++++ .../InstantScalarOpenApiCustomizerTest.java | 174 ++++++++++++ .../openapi/InstantScalarSchemaGraphTest.java | 256 ++++++++++++++++++ 9 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 modules/web/src/main/java/org/fireflyframework/web/openapi/InstantScalarOpenAPIAutoConfiguration.java create mode 100644 modules/web/src/main/java/org/fireflyframework/web/openapi/InstantScalarOpenApiCustomizer.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarOpenAPIAutoConfigurationTest.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarOpenApiCustomizerTest.java create mode 100644 modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarSchemaGraphTest.java diff --git a/docs/site/modules/web.md b/docs/site/modules/web.md index f359a6c06..a7180591a 100644 --- a/docs/site/modules/web.md +++ b/docs/site/modules/web.md @@ -433,6 +433,43 @@ public class OpenApiGenApplication { The Maven profile and plugin configuration are inherited from `fireflyframework-parent`. +### Named Instant schemas + +Use the named scalar when an owner field is a real `java.time.Instant` and its generated client must retain the instant, offset interpretation, and nanoseconds. Existing `LocalDateTime` fields can keep their current SDK configuration. + +Use a framework revision containing this registrar; earlier artifacts do not implement the flag. The capability is disabled by default. Enable it explicitly in the application configuration **and** in the original producer application's `openapi-gen` profile: + +```yaml +firefly: + openapi: + instant-scalar: + enabled: true +``` + +Normal auto-configuration and `@EnableOpenApiGen` import the same property-gated registrar. A coincidentally named domain schema has no effect while this property is missing or false. + +Annotate only the actual Instant field: + +```java +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; + +public record Observation( + @Schema(ref = "#/components/schemas/FireflyInstant") Instant recordedAt) {} +``` + +When an enabled document references this reserved name, the registrar adds `FireflyInstant` as the primitive `type: string`, `format: date-time` schema. An existing different definition fails generation instead of being overwritten. The registrar does not change runtime JSON serializers or the global ModelConverters registry. It visits modeled OpenAPI 3.0/3.1 schema locations, including path parameters, webhooks, component path items, and encoding headers; examples, descriptions, and arbitrary extension data are not opt-in signals, and remote references are not resolved. + +In the owner SDK's original `openapi-generator-maven-plugin` execution, use the standard mapping **list** supported by generator 7.26: + +```xml + + FireflyInstant=java.time.Instant + +``` + +Keep an existing `java8-localdatetime` for legacy fields. The explicit named mapping applies to the annotated scalar, rather than changing all date-time models. Generate the schema and SDK through the owner's original producer; do not edit the generated schema or Java files. Inspect generated field types and verify client serialization/deserialization against the real owner response before adopting this in a service. Framework source tests and generator contract vectors do not establish that service's producer, transport, release, or deployment readiness. + ## Continue the application Use the [Security Resource Server guide](security-resource-server.md) for the next part of this integration. The runnable submission/query service is in [Getting started](../getting-started.md). diff --git a/modules/web/README.md b/modules/web/README.md index 9a873ed48..0f2481d66 100644 --- a/modules/web/README.md +++ b/modules/web/README.md @@ -134,6 +134,20 @@ The `firefly.web.cors` namespace is bound by [CorsProperties.java](https://githu | [`ErrorHandlingProperties`](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/web/src/main/java/org/fireflyframework/web/error/config/ErrorHandlingProperties.java) | Configuration properties for error handling. | | [`PiiMaskingProperties`](https://github.com/fireflyframework/fireflyframework-jafly/blob/main/modules/web/src/main/java/org/fireflyframework/web/logging/config/PiiMaskingProperties.java) | Configuration properties for PII (Personally Identifiable Information) masking in logs. | +## Preserve Instant values in generated clients + +Use a framework revision containing this registrar; earlier artifacts do not implement the flag. The named Instant schema capability is off by default. Set `firefly.openapi.instant-scalar.enabled=true` in both the application and its original `openapi-gen` producer configuration. Mark an actual `Instant` field with `@Schema(ref = "#/components/schemas/FireflyInstant")`; enabled documents register a primitive string/date-time schema and reject a conflicting reserved definition. Default consumers, including coincidentally named domain schemas, remain unchanged. + +Use the generator's standard mapping list in the owner SDK execution: + +```xml + + FireflyInstant=java.time.Instant + +``` + +Keep existing legacy `dateLibrary` settings. The [named Instant schema guide](../../docs/site/modules/web.md#named-instant-schemas) shows the actual owner annotation and the application/producer opt-in. Qualify the owner's original producer, genuine SDK, and real responses before service adoption; these framework source controls do not establish that adoption or a released artifact. + ## Continue the application Use the [Security Resource Server guide](../../docs/site/modules/security-resource-server.md) for the next part of this integration. diff --git a/modules/web/src/main/java/org/fireflyframework/web/openapi/EnableOpenApiGen.java b/modules/web/src/main/java/org/fireflyframework/web/openapi/EnableOpenApiGen.java index 17c2772ca..d7f347fee 100644 --- a/modules/web/src/main/java/org/fireflyframework/web/openapi/EnableOpenApiGen.java +++ b/modules/web/src/main/java/org/fireflyframework/web/openapi/EnableOpenApiGen.java @@ -70,7 +70,8 @@ Jackson2AutoConfiguration.class, CodecsAutoConfiguration.class, SpringApplicationAdminJmxAutoConfiguration.class, - IdempotencyOpenAPIAutoConfiguration.class + IdempotencyOpenAPIAutoConfiguration.class, + InstantScalarOpenAPIAutoConfiguration.class }) public @interface EnableOpenApiGen { } diff --git a/modules/web/src/main/java/org/fireflyframework/web/openapi/InstantScalarOpenAPIAutoConfiguration.java b/modules/web/src/main/java/org/fireflyframework/web/openapi/InstantScalarOpenAPIAutoConfiguration.java new file mode 100644 index 000000000..993086e3d --- /dev/null +++ b/modules/web/src/main/java/org/fireflyframework/web/openapi/InstantScalarOpenAPIAutoConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.web.openapi; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnProperty( + prefix = "firefly.openapi.instant-scalar", + name = "enabled", + havingValue = "true") +public class InstantScalarOpenAPIAutoConfiguration { + @Bean + @ConditionalOnMissingBean(InstantScalarOpenApiCustomizer.class) + public InstantScalarOpenApiCustomizer fireflyInstantScalarOpenApiCustomizer() { + return new InstantScalarOpenApiCustomizer(); + } +} diff --git a/modules/web/src/main/java/org/fireflyframework/web/openapi/InstantScalarOpenApiCustomizer.java b/modules/web/src/main/java/org/fireflyframework/web/openapi/InstantScalarOpenApiCustomizer.java new file mode 100644 index 000000000..44c87f7a3 --- /dev/null +++ b/modules/web/src/main/java/org/fireflyframework/web/openapi/InstantScalarOpenApiCustomizer.java @@ -0,0 +1,133 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.web.openapi; + +import com.fasterxml.jackson.databind.JsonNode; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.headers.Header; +import io.swagger.v3.oas.models.media.Encoding; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; + +import org.springdoc.core.customizers.OpenApiCustomizer; + +import java.util.*; + +/** Registers the reserved RFC3339 scalar only when source schemas explicitly reference it. */ +public final class InstantScalarOpenApiCustomizer implements OpenApiCustomizer { + public static final String SCHEMA_NAME = "FireflyInstant"; + public static final String SCHEMA_REF = "#/components/schemas/" + SCHEMA_NAME; + + @Override + public void customise(OpenAPI api) { + if (api == null || !references(api, Collections.newSetFromMap(new IdentityHashMap<>()))) + return; + if (api.getComponents() == null) api.setComponents(new Components()); + var standard = new StringSchema().format("date-time"); + var existing = + api.getComponents().getSchemas() == null + ? null + : api.getComponents().getSchemas().get(SCHEMA_NAME); + if (existing == null) { + api.getComponents().addSchemas(SCHEMA_NAME, standard); + return; + } + JsonNode actual = Json.mapper().valueToTree(existing); + JsonNode expected = Json.mapper().valueToTree(standard); + if (!expected.equals(actual)) + throw new IllegalStateException("OPENAPI_INSTANT_SCHEMA_CONFLICT"); + } + + private boolean references(Object value, Set visited) { + if (value == null || !visited.add(value)) return false; + if (value instanceof Schema schema) { + return SCHEMA_REF.equals(schema.get$ref()) + || SCHEMA_REF.equals(schema.get$dynamicRef()) + || references(schema.getProperties(), visited) + || references(schema.getItems(), visited) + || references(schema.getAdditionalProperties(), visited) + || references(schema.getAllOf(), visited) + || references(schema.getAnyOf(), visited) + || references(schema.getOneOf(), visited) + || references(schema.getNot(), visited) + || references(schema.getContains(), visited) + || references(schema.getPatternProperties(), visited) + || references(schema.getPrefixItems(), visited) + || references(schema.getContentSchema(), visited) + || references(schema.getPropertyNames(), visited) + || references(schema.getUnevaluatedProperties(), visited) + || references(schema.getAdditionalItems(), visited) + || references(schema.getUnevaluatedItems(), visited) + || references(schema.getIf(), visited) + || references(schema.getThen(), visited) + || references(schema.getElse(), visited) + || references(schema.getDependentSchemas(), visited); + } + if (value instanceof OpenAPI api) { + return references(api.getComponents(), visited) + || references(api.getPaths(), visited) + || references(api.getWebhooks(), visited); + } + if (value instanceof Components components) { + return references(components.getSchemas(), visited) + || references(components.getParameters(), visited) + || references(components.getRequestBodies(), visited) + || references(components.getResponses(), visited) + || references(components.getHeaders(), visited) + || references(components.getCallbacks(), visited) + || references(components.getPathItems(), visited); + } + if (value instanceof PathItem path) + return references(path.getParameters(), visited) + || references(path.readOperations(), visited); + if (value instanceof Operation operation) { + return references(operation.getParameters(), visited) + || references(operation.getRequestBody(), visited) + || references(operation.getResponses(), visited) + || references(operation.getCallbacks(), visited); + } + if (value instanceof Parameter parameter) { + return references(parameter.getSchema(), visited) + || references(parameter.getContent(), visited); + } + if (value instanceof RequestBody body) return references(body.getContent(), visited); + if (value instanceof ApiResponse response) { + return references(response.getContent(), visited) + || references(response.getHeaders(), visited); + } + if (value instanceof Header header) { + return references(header.getSchema(), visited) + || references(header.getContent(), visited); + } + if (value instanceof MediaType media) + return references(media.getSchema(), visited) + || references(media.getEncoding(), visited); + if (value instanceof Encoding encoding) return references(encoding.getHeaders(), visited); + if (value instanceof Map map) { + for (Object nested : map.values()) if (references(nested, visited)) return true; + } else if (value instanceof Iterable values) { + for (Object nested : values) if (references(nested, visited)) return true; + } + return false; + } +} diff --git a/modules/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 97d2b80bf..14af78533 100644 --- a/modules/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/modules/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -7,3 +7,4 @@ org.fireflyframework.web.conditions.config.ConditionsReportPropertiesAutoConfigu org.fireflyframework.web.conditions.config.ConditionsReportAutoConfiguration org.fireflyframework.web.cors.config.CorsAutoConfiguration org.fireflyframework.web.jackson.config.JsonNullableAutoConfiguration +org.fireflyframework.web.openapi.InstantScalarOpenAPIAutoConfiguration diff --git a/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarOpenAPIAutoConfigurationTest.java b/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarOpenAPIAutoConfigurationTest.java new file mode 100644 index 000000000..c4166b5ad --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarOpenAPIAutoConfigurationTest.java @@ -0,0 +1,110 @@ +package org.fireflyframework.web.openapi; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class InstantScalarOpenAPIAutoConfigurationTest { + @Test + void normalConfigurationRegistersOnlyOneStatelessCustomizerWithoutBusinessBeans() { + new ApplicationContextRunner() + .withPropertyValues("firefly.openapi.instant-scalar.enabled=true") + .withConfiguration( + AutoConfigurations.of(InstantScalarOpenAPIAutoConfiguration.class)) + .run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(InstantScalarOpenApiCustomizer.class) + .doesNotHaveBean(io.swagger.v3.oas.models.OpenAPI.class)); + } + + @Test + void originalProducerMetaAnnotationImportsTheSameConfiguration() { + assertThat(EnableOpenApiGen.class.getAnnotation(ImportAutoConfiguration.class).value()) + .contains(InstantScalarOpenAPIAutoConfiguration.class); + } + + @Test + void normalAutoConfigurationMetadataIncludesTheSameConfiguration() throws Exception { + var resources = + getClass() + .getClassLoader() + .getResources( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"); + var webMetadata = new java.util.ArrayList(); + while (resources.hasMoreElements()) { + try (var stream = resources.nextElement().openStream()) { + String content = + new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + if (content.contains("org.fireflyframework.web.openapi.OpenAPIAutoConfiguration")) + webMetadata.add(content); + } + } + assertThat(webMetadata).isNotEmpty(); + assertThat(webMetadata) + .anyMatch( + content -> + content.contains( + "org.fireflyframework.web.openapi.InstantScalarOpenAPIAutoConfiguration")); + } + + @Test + void defaultDoesNotTreatAnExistingDomainSchemaAsAnInstantOptIn() throws Exception { + var api = + new io.swagger.v3.oas.models.OpenAPI() + .components( + new io.swagger.v3.oas.models.Components() + .addSchemas( + "FireflyInstant", + new io.swagger.v3.oas.models.media.ObjectSchema() + .addProperty( + "domainId", + new io.swagger.v3.oas.models.media + .StringSchema())) + .addSchemas( + "OwnerDto", + new io.swagger.v3.oas.models.media.Schema<>() + .$ref( + "#/components/schemas/FireflyInstant"))); + String before = io.swagger.v3.core.util.Json.pretty(api); + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(InstantScalarOpenAPIAutoConfiguration.class)) + .run( + context -> { + assertThatCode( + () -> + context.getBeansOfType( + org.springdoc.core.customizers + .OpenApiCustomizer + .class) + .values() + .forEach( + customizer -> + customizer.customise( + api))) + .doesNotThrowAnyException(); + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(InstantScalarOpenApiCustomizer.class); + }); + assertThat(io.swagger.v3.core.util.Json.pretty(api)).isEqualTo(before); + } + + @Test + void explicitlyDisabledConfigurationDoesNotCreateARegistrar() { + new ApplicationContextRunner() + .withPropertyValues("firefly.openapi.instant-scalar.enabled=false") + .withConfiguration( + AutoConfigurations.of(InstantScalarOpenAPIAutoConfiguration.class)) + .run( + context -> + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(InstantScalarOpenApiCustomizer.class)); + } +} diff --git a/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarOpenApiCustomizerTest.java b/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarOpenApiCustomizerTest.java new file mode 100644 index 000000000..56070d54f --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarOpenApiCustomizerTest.java @@ -0,0 +1,174 @@ +package org.fireflyframework.web.openapi; + +import static org.assertj.core.api.Assertions.*; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.media.*; +import io.swagger.v3.oas.models.parameters.*; +import io.swagger.v3.oas.models.responses.*; + +import org.junit.jupiter.api.Test; +import org.springdoc.core.customizers.OpenApiCustomizer; + +import java.util.*; + +class InstantScalarOpenApiCustomizerTest { + final String ref = "#/components/schemas/FireflyInstant"; + + OpenApiCustomizer customizer() { + try { + return (OpenApiCustomizer) + Class.forName("org.fireflyframework.web.openapi.InstantScalarOpenApiCustomizer") + .getConstructor() + .newInstance(); + } catch (ReflectiveOperationException error) { + throw new AssertionError("Missing opt-in instant schema registrar", error); + } + } + + Schema marker() { + return new Schema<>().$ref(ref); + } + + OpenAPI components(Schema schema) { + return new OpenAPI().components(new Components().addSchemas("OwnerDto", schema)); + } + + void registered(OpenAPI api) { + customizer().customise(api); + com.fasterxml.jackson.databind.JsonNode actual = + Json.mapper().valueToTree(api.getComponents().getSchemas().get("FireflyInstant")); + assertThat(actual) + .isEqualTo(Json.mapper().valueToTree(new StringSchema().format("date-time"))); + } + + @Test + void defaultConsumerIsByteUnchangedWithoutAnExplicitReference() throws Exception { + var api = + components( + new ObjectSchema() + .addProperty("legacyTime", new StringSchema().format("date-time"))); + String before = Json.pretty(api); + customizer().customise(api); + assertThat(Json.pretty(api)).isEqualTo(before); + } + + @Test + void anExampleOrDescriptionIsNotAnOptIn() throws Exception { + var api = components(new StringSchema().description(ref).example(Map.of("$ref", ref))); + String before = Json.pretty(api); + customizer().customise(api); + assertThat(Json.pretty(api)).isEqualTo(before); + } + + @Test + void explicitDtoPropertyRegistersTheExactRfc3339Scalar() { + registered(components(new ObjectSchema().addProperty("recordedAt", marker()))); + } + + @Test + void arrayAndCompositionReferencesAreVisited() { + var api = components(new ComposedSchema().addAllOfItem(new ArraySchema().items(marker()))); + registered(api); + } + + @Test + void pathResponseSchemaRegistersWithoutExistingComponents() { + registered( + new OpenAPI() + .paths( + new Paths() + .addPathItem( + "/metadata", + new PathItem() + .get( + new Operation() + .responses( + new ApiResponses() + .addApiResponse( + "200", + new ApiResponse() + .description( + "metadata") + .content( + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + marker()))))))))); + } + + @Test + void requestAndParameterAndReusableComponentSchemasAreVisited() { + for (var api : + List.of( + new OpenAPI() + .paths( + new Paths() + .addPathItem( + "/metadata", + new PathItem() + .post( + new Operation() + .requestBody( + new RequestBody() + .content( + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + marker()))))))), + new OpenAPI() + .components( + new Components() + .addParameters( + "at", + new Parameter() + .name("at") + .in("query") + .schema(marker()))))) { + registered(api); + } + } + + @Test + void anEquivalentExistingScalarIsPreservedWithoutReplacement() { + var scalar = new StringSchema().format("date-time"); + var api = components(marker()); + api.getComponents().addSchemas("FireflyInstant", scalar); + registered(api); + assertThat(api.getComponents().getSchemas().get("FireflyInstant")).isSameAs(scalar); + } + + @Test + void aConflictingReservedScalarFailsInsteadOfChangingItsMeaning() { + var api = components(marker()); + api.getComponents().addSchemas("FireflyInstant", new StringSchema().format("date")); + assertThatThrownBy(() -> customizer().customise(api)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("OPENAPI_INSTANT_SCHEMA_CONFLICT"); + } + + @Test + void schemaCyclesDoNotRequireGlobalModelRegistryState() { + var schema = new ObjectSchema(); + schema.addProperty("recursive", schema); + var api = components(schema); + customizer().customise(api); + assertThat(api.getComponents().getSchemas()).doesNotContainKey("FireflyInstant"); + schema.addProperty("at", marker()); + registered(api); + } + + @Test + void repeatedCustomizationIsIdempotent() throws Exception { + var api = components(marker()); + registered(api); + String once = Json.pretty(api); + customizer().customise(api); + assertThat(Json.pretty(api)).isEqualTo(once); + } +} diff --git a/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarSchemaGraphTest.java b/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarSchemaGraphTest.java new file mode 100644 index 000000000..7d730c85e --- /dev/null +++ b/modules/web/src/test/java/org/fireflyframework/web/openapi/InstantScalarSchemaGraphTest.java @@ -0,0 +1,256 @@ +package org.fireflyframework.web.openapi; + +import static org.assertj.core.api.Assertions.*; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.headers.Header; +import io.swagger.v3.oas.models.media.*; +import io.swagger.v3.oas.models.parameters.*; +import io.swagger.v3.oas.models.responses.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Stream; + +class InstantScalarSchemaGraphTest { + static final String REF = InstantScalarOpenApiCustomizer.SCHEMA_REF; + + record Site(String name, Function, OpenAPI> document) {} + + static OpenAPI schema(Schema schema) { + return new OpenAPI() + .openapi("3.1.0") + .components(new Components().addSchemas("OwnerDto", schema)); + } + + static ApiResponse response(Schema schema) { + return new ApiResponse() + .description("metadata") + .content( + new Content() + .addMediaType("application/json", new MediaType().schema(schema))); + } + + static PathItem pathResponse(Schema schema) { + return new PathItem() + .get( + new Operation() + .responses( + new ApiResponses() + .addApiResponse("200", response(schema)))); + } + + static PathItem pathParameter(Schema schema) { + return new PathItem() + .addParametersItem(new Parameter().name("at").in("query").schema(schema)); + } + + static Map schemas(Schema child) { + return Map.of("child", child); + } + + static Stream sites() { + List sites = + List.of( + new Site( + "path parameter schema", + m -> + new OpenAPI() + .paths( + new Paths() + .addPathItem( + "/metadata", + pathParameter(m)))), + new Site( + "path parameter content", + m -> + new OpenAPI() + .paths( + new Paths() + .addPathItem( + "/metadata", + new PathItem() + .addParametersItem( + new Parameter() + .name( + "at") + .in( + "query") + .content( + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + m))))))), + new Site( + "webhook response", + m -> new OpenAPI().webhooks(Map.of("metadata", pathResponse(m)))), + new Site( + "component path item", + m -> + new OpenAPI() + .components( + new Components() + .pathItems( + Map.of( + "metadata", + pathParameter( + m))))), + new Site( + "encoding header schema", + m -> + new OpenAPI() + .paths( + new Paths() + .addPathItem( + "/metadata", + pathResponseWithEncoding( + new Header() + .schema( + m))))), + new Site( + "encoding header content", + m -> + new OpenAPI() + .paths( + new Paths() + .addPathItem( + "/metadata", + pathResponseWithEncoding( + new Header() + .content( + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + m))))))), + new Site( + "schema properties", + m -> schema(new ObjectSchema().addProperty("at", m))), + new Site("schema items", m -> schema(new ArraySchema().items(m))), + new Site( + "schema additionalProperties", + m -> schema(new ObjectSchema().additionalProperties(m))), + new Site("schema allOf", m -> schema(new ComposedSchema().addAllOfItem(m))), + new Site("schema anyOf", m -> schema(new ComposedSchema().addAnyOfItem(m))), + new Site("schema oneOf", m -> schema(new ComposedSchema().addOneOfItem(m))), + new Site( + "schema dynamicRef", + m -> schema(new Schema<>().$dynamicRef(m.get$ref()))), + new Site("schema not", m -> schema(new Schema<>().not(m))), + new Site("schema contains", m -> schema(new Schema<>().contains(m))), + new Site( + "schema patternProperties", + m -> schema(new Schema<>().patternProperties(schemas(m)))), + new Site( + "schema prefixItems", + m -> schema(new Schema<>().prefixItems(List.of(m)))), + new Site( + "schema contentSchema", + m -> schema(new Schema<>().contentSchema(m))), + new Site( + "schema propertyNames", + m -> schema(new Schema<>().propertyNames(m))), + new Site( + "schema unevaluatedProperties", + m -> schema(new Schema<>().unevaluatedProperties(m))), + new Site( + "schema additionalItems", + m -> schema(new Schema<>().additionalItems(m))), + new Site( + "schema unevaluatedItems", + m -> schema(new Schema<>().unevaluatedItems(m))), + new Site("schema if", m -> schema(new Schema<>()._if(m))), + new Site( + "schema then", + m -> { + var s = new Schema<>(); + s.setThen(m); + return schema(s); + }), + new Site("schema else", m -> schema(new Schema<>()._else(m))), + new Site( + "schema dependentSchemas", + m -> schema(new Schema<>().dependentSchemas(schemas(m))))); + return sites.stream().map(site -> Arguments.of(site.name(), site.document())); + } + + static PathItem pathResponseWithEncoding(Header header) { + var media = + new MediaType() + .encoding(Map.of("metadata", new Encoding().headers(Map.of("At", header)))); + return new PathItem() + .get( + new Operation() + .responses( + new ApiResponses() + .addApiResponse( + "200", + new ApiResponse() + .description("metadata") + .content( + new Content() + .addMediaType( + "multipart/mixed", + media))))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("sites") + void everySupportedSchemaLocationRegistersThePrimitive( + String name, Function, OpenAPI> site) { + var api = site.apply(new Schema<>().$ref(REF)); + new InstantScalarOpenApiCustomizer().customise(api); + assertThat(api.getComponents()).as(name).isNotNull(); + assertThat(api.getComponents().getSchemas()).as(name).containsKey("FireflyInstant"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("sites") + void everySupportedSchemaLocationEnforcesTheEnabledReservedContract( + String name, Function, OpenAPI> site) { + var api = site.apply(new Schema<>().$ref(REF)); + if (api.getComponents() == null) api.setComponents(new Components()); + api.getComponents().addSchemas("FireflyInstant", new ObjectSchema()); + assertThatThrownBy(() -> new InstantScalarOpenApiCustomizer().customise(api)) + .as(name) + .isInstanceOf(IllegalStateException.class) + .hasMessage("OPENAPI_INSTANT_SCHEMA_CONFLICT"); + } + + @Test + void schema31CyclesTerminateWithoutTreatingBackingMetadataAsSchemaLocations() { + var parent = new Schema<>(); + parent.setContains(parent); + parent.setPrefixItems(List.of(parent)); + parent.setJsonSchema(Map.of("$ref", REF)); + parent.setJsonSchemaImpl(Map.of("$ref", REF)); + var api = schema(parent); + new InstantScalarOpenApiCustomizer().customise(api); + assertThat(api.getComponents().getSchemas()).doesNotContainKey("FireflyInstant"); + parent.setPropertyNames(new Schema<>().$ref(REF)); + new InstantScalarOpenApiCustomizer().customise(api); + assertThat(api.getComponents().getSchemas()).containsKey("FireflyInstant"); + } + + @Test + void theScalarEmitsStringDateTimeUnderBothSwaggerSerializers() { + var api = schema(new Schema<>().$ref(REF)); + new InstantScalarOpenApiCustomizer().customise(api); + var scalar = api.getComponents().getSchemas().get("FireflyInstant"); + for (var mapper : List.of(Json.mapper(), Json31.mapper())) { + com.fasterxml.jackson.databind.JsonNode tree = mapper.valueToTree(scalar); + assertThat(tree.path("type").asText()).isEqualTo("string"); + assertThat(tree.path("format").asText()).isEqualTo("date-time"); + } + } +} From 3ee01682dc4ad5afcb615278e4ca08bb670d6a0a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 08:39:39 -0700 Subject: [PATCH 19/44] Restore production OAuth2 client registration on Boot 4 --- modules/security-oauth2-client/pom.xml | 9 +- ...Auth2ClientProductionRegistrationTest.java | 164 ++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 modules/security-oauth2-client/src/test/java/org/fireflyframework/security/client/OAuth2ClientProductionRegistrationTest.java diff --git a/modules/security-oauth2-client/pom.xml b/modules/security-oauth2-client/pom.xml index 9917eb8e4..6e68c4008 100644 --- a/modules/security-oauth2-client/pom.xml +++ b/modules/security-oauth2-client/pom.xml @@ -31,6 +31,10 @@ org.springframework.boot spring-boot-autoconfigure + + org.springframework.boot + spring-boot-security-oauth2-client + org.slf4j @@ -44,8 +48,9 @@ - org.springframework.boot - spring-boot-starter-oauth2-client + org.fireflyframework + fireflyframework-security-resource-server + ${fireflyframework.version} test diff --git a/modules/security-oauth2-client/src/test/java/org/fireflyframework/security/client/OAuth2ClientProductionRegistrationTest.java b/modules/security-oauth2-client/src/test/java/org/fireflyframework/security/client/OAuth2ClientProductionRegistrationTest.java new file mode 100644 index 000000000..a82fa4c8f --- /dev/null +++ b/modules/security-oauth2-client/src/test/java/org/fireflyframework/security/client/OAuth2ClientProductionRegistrationTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.security.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.fireflyframework.security.api.domain.Decision; +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.security.rs.JwtToFireflyPrincipalConverter; +import org.fireflyframework.security.rs.ResourceServerAutoConfiguration; +import org.fireflyframework.security.spi.PolicyDecisionPort; +import org.fireflyframework.security.webflux.authentication.FireflyAuthenticationToken; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.web.server.handler.DefaultWebFilterChain; +import reactor.core.publisher.Mono; + +class OAuth2ClientProductionRegistrationTest { + private static final Duration WAIT = Duration.ofSeconds(5); + private static final List BOOT_CLIENT_CONFIGURATIONS = List.of( + "org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration", + "org.springframework.boot.security.autoconfigure.web.reactive.ReactiveWebSecurityAutoConfiguration", + "org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientAutoConfiguration", + "org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientWebSecurityAutoConfiguration"); + + private ReactiveWebApplicationContextRunner runner(boolean resourceServer) { + var configurations = new ArrayList>(); + configurations.add(OAuth2ClientAutoConfiguration.class); + var loader = getClass().getClassLoader(); + for (var name : ImportCandidates.load(AutoConfiguration.class, loader)) { + if (BOOT_CLIENT_CONFIGURATIONS.contains(name)) { + try { + configurations.add(Class.forName(name, false, loader)); + } catch (ClassNotFoundException missing) { + throw new IllegalStateException(missing); + } + } + } + if (resourceServer) configurations.add(ResourceServerAutoConfiguration.class); + return new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(configurations.toArray(Class[]::new))); + } + + private String[] registration() { + return new String[] { + "spring.security.oauth2.client.registration.worker.provider=fixture", + "spring.security.oauth2.client.registration.worker.client-id=source-unit-worker", + "spring.security.oauth2.client.registration.worker.client-secret=source-unit-fixture-value", + "spring.security.oauth2.client.registration.worker.authorization-grant-type=client_credentials", + "spring.security.oauth2.client.provider.fixture.token-uri=https://idp.example.invalid/oauth2/token"}; + } + + @Test + void standardRegistrationFromProductionDependenciesCreatesBackgroundClientBeans() { + runner(false).withPropertyValues(registration()).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(ReactiveClientRegistrationRepository.class); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientService.class); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class); + assertThat(context).hasSingleBean(ServerOAuth2AuthorizedClientExchangeFilterFunction.class); + var registration = context.getBean(ReactiveClientRegistrationRepository.class) + .findByRegistrationId("worker").block(WAIT); + assertThat(registration).isNotNull(); + assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS); + assertThat(registration.getClientId()).isEqualTo("source-unit-worker"); + assertThat(context.getBean(ReactiveOAuth2AuthorizedClientManager.class)) + .isInstanceOf(AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager.class); + }); + } + + @Test + void noRegistrationCreatesNoClientIdentityOrManager() { + runner(false).run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(ReactiveClientRegistrationRepository.class); + assertThat(context).doesNotHaveBean(ReactiveOAuth2AuthorizedClientManager.class); + }); + } + + @Test + void outboundClientLeavesResourceServerJwtChainAndAnonymousDefaultDenialInPlace() { + runner(true).withPropertyValues(registration()).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(SecurityWebFilterChain.class); + assertThat(context).hasBean("fireflySecurityWebFilterChain"); + assertThat(context.getBean(ReactiveJwtDecoder.class)).isInstanceOf(NimbusReactiveJwtDecoder.class); + assertThat(context).hasSingleBean(JwtToFireflyPrincipalConverter.class); + var chain = context.getBean(SecurityWebFilterChain.class); + var filters = chain.getWebFilters().collectList().block(WAIT); + assertThat(filters).noneMatch(filter -> filter.getClass().getName().contains("OAuth2Login") + || filter.getClass().getName().contains("OAuth2AuthorizationRequestRedirect")); + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/protected")); + new DefaultWebFilterChain(e -> e.getResponse().setComplete(), filters).filter(exchange).block(WAIT); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + }); + } + + @Test + void outboundClientPreservesCurrentUrlPolicySelectionInTheResourceServerChain() { + var allowed = new AtomicBoolean(false); + var calls = new AtomicInteger(); + PolicyDecisionPort policy = (principal, action, resource, data) -> { + assertThat(principal.subject()).isEqualTo("source-unit-principal"); + assertThat(action).isEqualTo("GET"); + assertThat(resource).isEqualTo("/protected"); + calls.incrementAndGet(); + return Mono.just(allowed.get() ? Decision.permit() : Decision.deny("source-unit-denial")); + }; + runner(true).withPropertyValues(registration()) + .withPropertyValues("firefly.security.resource-server.strict-policy=true") + .withBean(PolicyDecisionPort.class, () -> policy).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(SecurityWebFilterChain.class); + var filters = context.getBean(SecurityWebFilterChain.class).getWebFilters().collectList().block(WAIT); + // An existing source-unit security context tests policy composition, not JWT authentication. + var authentication = new FireflyAuthenticationToken(SecurityPrincipal.builder() + .subject("source-unit-principal").build()); + for (boolean permit : new boolean[] {false, true, false}) { + allowed.set(permit); + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/protected")); + new DefaultWebFilterChain(e -> { + e.getResponse().setStatusCode(HttpStatus.NO_CONTENT); + return e.getResponse().setComplete(); + }, filters).filter(exchange) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)) + .block(WAIT); + assertThat(exchange.getResponse().getStatusCode()) + .isEqualTo(permit ? HttpStatus.NO_CONTENT : HttpStatus.FORBIDDEN); + } + assertThat(calls.get()).isEqualTo(3); + }); + } +} From f6e3cc20cf6490281afb6c6e8005c252e811f46e Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 14:15:11 -0700 Subject: [PATCH 20/44] Protect temporary outputs and remove empty credential defaults --- .../multipart/MultipartUploadHelper.java | 2 +- .../helpers/MultipartUploadHelperTest.java | 24 ++++++++++ .../src/main/resources/application.yml | 3 +- .../config/EventStoreTestCredentialsTest.java | 46 +++++++++++++++++++ .../application-messaging-example.yml | 8 ++-- .../application-webclient-example.yml | 4 +- .../ExampleCredentialConfigurationTest.java | 45 ++++++++++++++++++ .../template/TemplateRenderUtilTest.java | 23 +++++++--- 8 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 modules/eventsourcing/src/test/java/org/fireflyframework/eventsourcing/config/EventStoreTestCredentialsTest.java create mode 100644 modules/starter-core/src/test/java/org/fireflyframework/core/config/ExampleCredentialConfigurationTest.java diff --git a/modules/client/src/main/java/org/fireflyframework/client/multipart/MultipartUploadHelper.java b/modules/client/src/main/java/org/fireflyframework/client/multipart/MultipartUploadHelper.java index 80d23288c..15ffed176 100644 --- a/modules/client/src/main/java/org/fireflyframework/client/multipart/MultipartUploadHelper.java +++ b/modules/client/src/main/java/org/fireflyframework/client/multipart/MultipartUploadHelper.java @@ -481,7 +481,7 @@ public Mono uploadFileCompressed(String endpoint, File file, String field * Compresses a file using GZIP. */ private File compressFile(File file) throws IOException { - File compressedFile = File.createTempFile("upload_", ".gz"); + File compressedFile = Files.createTempFile("upload_", ".gz").toFile(); try (FileInputStream fis = new FileInputStream(file); FileOutputStream fos = new FileOutputStream(compressedFile); diff --git a/modules/client/src/test/java/org/fireflyframework/client/helpers/MultipartUploadHelperTest.java b/modules/client/src/test/java/org/fireflyframework/client/helpers/MultipartUploadHelperTest.java index 8a924067f..3ba79a5f1 100644 --- a/modules/client/src/test/java/org/fireflyframework/client/helpers/MultipartUploadHelperTest.java +++ b/modules/client/src/test/java/org/fireflyframework/client/helpers/MultipartUploadHelperTest.java @@ -25,6 +25,30 @@ @DisplayName("Multipart Upload Helper Tests") class MultipartUploadHelperTest { + @Test + void compressedTemporaryFileIsOwnerOnlyAndContainsTheOriginalBytes(@TempDir Path directory) + throws Exception { + Path input = java.nio.file.Files.writeString(directory.resolve("input.txt"), "owned upload content"); + var helper = new MultipartUploadHelper("http://localhost:8080"); + var compress = MultipartUploadHelper.class.getDeclaredMethod("compressFile", File.class); + compress.setAccessible(true); + File compressed = (File) compress.invoke(helper, input.toFile()); + try { + if (compressed.toPath().getFileSystem().supportedFileAttributeViews().contains("posix")) { + assertThat(java.nio.file.Files.getPosixFilePermissions(compressed.toPath())) + .containsExactlyInAnyOrder( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE); + } + try (var gzip = new java.util.zip.GZIPInputStream(java.nio.file.Files.newInputStream(compressed.toPath()))) { + assertThat(new String(gzip.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)) + .isEqualTo("owned upload content"); + } + } finally { + java.nio.file.Files.deleteIfExists(compressed.toPath()); + } + } + @Test @DisplayName("Should create upload helper with valid base URL") void shouldCreateUploadHelperWithValidBaseUrl() { diff --git a/modules/eventsourcing/src/main/resources/application.yml b/modules/eventsourcing/src/main/resources/application.yml index a32c51f8d..502e3c9a6 100644 --- a/modules/eventsourcing/src/main/resources/application.yml +++ b/modules/eventsourcing/src/main/resources/application.yml @@ -125,7 +125,8 @@ spring: r2dbc: url: r2dbc:h2:mem:///testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL username: sa - password: "" + # Supply an ephemeral credential when selecting this standalone test profile. + password: ${EVENT_STORE_TEST_PASSWORD} flyway: enabled: false # Managed by test setup diff --git a/modules/eventsourcing/src/test/java/org/fireflyframework/eventsourcing/config/EventStoreTestCredentialsTest.java b/modules/eventsourcing/src/test/java/org/fireflyframework/eventsourcing/config/EventStoreTestCredentialsTest.java new file mode 100644 index 000000000..f3b0e23ec --- /dev/null +++ b/modules/eventsourcing/src/test/java/org/fireflyframework/eventsourcing/config/EventStoreTestCredentialsTest.java @@ -0,0 +1,46 @@ +package org.fireflyframework.eventsourcing.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.env.MockEnvironment; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class EventStoreTestCredentialsTest { + private MockEnvironment testProfile() throws Exception { + var environment = new MockEnvironment(); + var source = new YamlPropertySourceLoader().load("event-store", new ClassPathResource("application.yml")) + .stream().filter(value -> "test".equals(value.getProperty("spring.config.activate.on-profile"))) + .findFirst().orElseThrow(); + environment.getPropertySources().addLast(source); + return environment; + } + + @Test + void testProfileHasNoImplicitEmptyCredential() throws Exception { + var environment = testProfile(); + assertThrows(IllegalArgumentException.class, () -> environment.getProperty("spring.r2dbc.password")); + } + + @Test + void explicitDynamicTestCredentialOverridesTheProfileWithoutAnEnvironmentSecret() throws Exception { + var environment = testProfile(); + String credential = UUID.randomUUID().toString(); + environment.setProperty("spring.r2dbc.password", credential); + assertEquals(credential, environment.getProperty("spring.r2dbc.password")); + } + + @Test + void explicitTestCredentialResolvesWithoutChangingTheInMemoryTarget() throws Exception { + var environment = testProfile(); + String credential = UUID.randomUUID().toString(); + environment.setProperty("EVENT_STORE_TEST_PASSWORD", credential); + assertEquals(credential, environment.getProperty("spring.r2dbc.password")); + assertEquals("r2dbc:h2:mem:///testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", environment.getProperty("spring.r2dbc.url")); + assertEquals("sa", environment.getProperty("spring.r2dbc.username")); + assertEquals(Boolean.FALSE, environment.getProperty("spring.flyway.enabled", Boolean.class)); + } +} diff --git a/modules/starter-core/src/main/resources/application-messaging-example.yml b/modules/starter-core/src/main/resources/application-messaging-example.yml index 8f5650a93..449b72e5e 100644 --- a/modules/starter-core/src/main/resources/application-messaging-example.yml +++ b/modules/starter-core/src/main/resources/application-messaging-example.yml @@ -205,8 +205,8 @@ messaging: host: localhost # Redis port port: 6379 - # Redis password - password: "" + # For authenticated Redis, opt in with an environment-supplied credential. + # password: ${MESSAGING_REDIS_PASSWORD} # Redis database index database: 0 # Connection timeout in milliseconds @@ -236,8 +236,8 @@ messaging: broker-url: tcp://localhost:61616 # JMS username username: "" - # JMS password - password: "" + # For authenticated JMS, opt in with an environment-supplied credential. + # password: ${MESSAGING_JMS_PASSWORD} # Client ID for JMS connection client-id: messaging-publisher # Connection factory class name diff --git a/modules/starter-core/src/main/resources/application-webclient-example.yml b/modules/starter-core/src/main/resources/application-webclient-example.yml index 638a2601b..03ecb0134 100644 --- a/modules/starter-core/src/main/resources/application-webclient-example.yml +++ b/modules/starter-core/src/main/resources/application-webclient-example.yml @@ -85,8 +85,8 @@ webclient: # Proxy username username: "" - # Proxy password - password: "" + # For an authenticated proxy, opt in with an environment-supplied credential. + # password: ${WEBCLIENT_PROXY_PASSWORD} # Non-proxy hosts (hosts that should bypass the proxy) non-proxy-hosts: diff --git a/modules/starter-core/src/test/java/org/fireflyframework/core/config/ExampleCredentialConfigurationTest.java b/modules/starter-core/src/test/java/org/fireflyframework/core/config/ExampleCredentialConfigurationTest.java new file mode 100644 index 000000000..7a57597a8 --- /dev/null +++ b/modules/starter-core/src/test/java/org/fireflyframework/core/config/ExampleCredentialConfigurationTest.java @@ -0,0 +1,45 @@ +package org.fireflyframework.core.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.env.MockEnvironment; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class ExampleCredentialConfigurationTest { + private void optionalCredential(String resource, String prefix, String variable) throws Exception { + var input = new ClassPathResource(resource); + var environment = new MockEnvironment(); + for (var source : new YamlPropertySourceLoader().load(resource, input)) + environment.getPropertySources().addLast(source); + assertEquals(Boolean.FALSE, environment.getProperty(prefix + ".enabled", Boolean.class)); + assertNull(environment.getProperty(prefix + ".password")); + String text = input.getContentAsString(StandardCharsets.UTF_8); + String placeholder = "${" + variable + "}"; + assertTrue(text.contains("# password: " + placeholder)); + assertThrows(IllegalArgumentException.class, + () -> environment.resolveRequiredPlaceholders(placeholder)); + String credential = UUID.randomUUID().toString(); + environment.setProperty(variable, credential); + assertEquals(credential, environment.resolveRequiredPlaceholders(placeholder)); + } + + @Test + void disabledRedisExampleHasOnlyExplicitOptInEnvironmentCredentials() throws Exception { + optionalCredential("application-messaging-example.yml", "messaging.redis", "MESSAGING_REDIS_PASSWORD"); + } + + @Test + void disabledJmsExampleHasOnlyExplicitOptInEnvironmentCredentials() throws Exception { + optionalCredential("application-messaging-example.yml", "messaging.jms", "MESSAGING_JMS_PASSWORD"); + } + + @Test + void disabledProxyExampleHasOnlyExplicitOptInEnvironmentCredentials() throws Exception { + optionalCredential("application-webclient-example.yml", "webclient.proxy", "WEBCLIENT_PROXY_PASSWORD"); + } +} diff --git a/modules/utils/src/test/java/org/fireflyframework/utils/template/TemplateRenderUtilTest.java b/modules/utils/src/test/java/org/fireflyframework/utils/template/TemplateRenderUtilTest.java index 199e4cdab..c34dfccec 100644 --- a/modules/utils/src/test/java/org/fireflyframework/utils/template/TemplateRenderUtilTest.java +++ b/modules/utils/src/test/java/org/fireflyframework/utils/template/TemplateRenderUtilTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayOutputStream; import java.io.File; @@ -188,10 +189,15 @@ void testRenderTemplateStringToPdfBytes() throws Exception { } @Test - void testRenderTemplateToPdfFile() throws Exception { + void testRenderTemplateToPdfFile(@TempDir Path directory) throws Exception { // Create a temporary file for the PDF output - File tempFile = File.createTempFile("test-pdf-file-", ".pdf"); - tempFile.deleteOnExit(); + File tempFile = Files.createTempFile(directory, "test-pdf-file-", ".pdf").toFile(); + if (tempFile.toPath().getFileSystem().supportedFileAttributeViews().contains("posix")) { + assertEquals(java.util.Set.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(tempFile.toPath())); + } // Render the template to a PDF file TemplateRenderUtil.renderTemplateToPdfFile( @@ -410,13 +416,18 @@ void testRenderHtmlToPdfBytesWithOptions() throws Exception { } @Test - void testRenderHtmlToPdfFile() throws Exception { + void testRenderHtmlToPdfFile(@TempDir Path directory) throws Exception { // Create a simple HTML content String html = "

    Direct HTML to PDF File Test

    "; // Create a temporary file for the PDF output - File tempFile = File.createTempFile("html-pdf-file-", ".pdf"); - tempFile.deleteOnExit(); + File tempFile = Files.createTempFile(directory, "html-pdf-file-", ".pdf").toFile(); + if (tempFile.toPath().getFileSystem().supportedFileAttributeViews().contains("posix")) { + assertEquals(java.util.Set.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(tempFile.toPath())); + } // Render HTML to PDF file TemplateRenderUtil.renderHtmlToPdfFile(html, tempFile.getAbsolutePath()); From bbefea0cb3ba7b836e1ca04d6fb18bd031928fbb Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 15:27:57 -0700 Subject: [PATCH 21/44] Escape provider log fields without exposing error payloads --- .../kernel/logging/LogFields.java | 51 ++++++ .../kernel/logging/LogFieldsTest.java | 171 ++++++++++++++++++ .../cognito/service/CognitoAdminService.java | 67 +++---- .../cognito/service/CognitoUserService.java | 19 +- .../azuread/service/AzureAdAuthService.java | 21 ++- .../idp/azuread/service/B2CAdminService.java | 13 +- .../azuread/service/EntraIdAdminService.java | 77 ++++---- .../adapter/InternalDbIdpAdapter.java | 69 +++---- .../service/AuthenticationService.java | 15 +- .../service/PasswordResetService.java | 7 +- .../idp/internaldb/service/RoleService.java | 16 +- .../service/UserManagementService.java | 7 +- .../service/impl/IdpAdminServiceImpl.java | 63 +++---- 13 files changed, 416 insertions(+), 180 deletions(-) create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/logging/LogFields.java create mode 100644 modules/kernel/src/test/java/org/fireflyframework/kernel/logging/LogFieldsTest.java diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/logging/LogFields.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/logging/LogFields.java new file mode 100644 index 000000000..1da032803 --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/logging/LogFields.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.logging; + +import java.util.Collection; +import java.util.StringJoiner; + +/** Single-line rendering for non-secret diagnostic fields; this does not redact secrets. */ +public final class LogFields { + private LogFields() { + } + + /** Preserves ordinary text and null while making line boundaries visible. */ + public static String text(String value) { + if (value == null) return null; + return value.replace("\r", "\\r").replace("\n", "\\n") + .replace("\u000b", "\\u000b").replace("\f", "\\f") + .replace("\u0085", "\\u0085").replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029"); + } + + /** Renders string-valued fields with normal collection order and null elements. */ + public static String values(Collection values) { + if (values == null) return null; + try { + var result = new StringJoiner(", ", "[", "]"); + for (String value : values) result.add(text(value)); + return result.toString(); + } catch (RuntimeException unavailable) { + return "[unavailable]"; + } + } + + /** Omits provider messages and stacks that can contain credentials or response payloads. */ + public static String errorType(Throwable error) { + return error == null ? null : error.getClass().getName(); + } +} diff --git a/modules/kernel/src/test/java/org/fireflyframework/kernel/logging/LogFieldsTest.java b/modules/kernel/src/test/java/org/fireflyframework/kernel/logging/LogFieldsTest.java new file mode 100644 index 000000000..867552271 --- /dev/null +++ b/modules/kernel/src/test/java/org/fireflyframework/kernel/logging/LogFieldsTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class LogFieldsTest { + static Stream lineBreaks() { + return Stream.of( + Arguments.of("\r", "\\r"), Arguments.of("\n", "\\n"), + Arguments.of("\u000b", "\\u000b"), Arguments.of("\f", "\\f"), + Arguments.of("\u0085", "\\u0085"), Arguments.of("\u2028", "\\u2028"), + Arguments.of("\u2029", "\\u2029")); + } + + @ParameterizedTest + @MethodSource("lineBreaks") + void lineBreaksRemainVisibleInsideOneField(String lineBreak, String expected) { + assertEquals("before" + expected + "after", LogFields.text("before" + lineBreak + "after")); + } + + @Test + void ordinaryTextKeepsIdentityUnicodeWhitespaceAndLiteralEscapeSequences() { + for (String value : List.of("alice@example.invalid", "tenant-1/user-2", "李 é ", "a\tb", "literal\\n")) { + assertSame(value, LogFields.text(value)); + } + } + + @Test + void nullAndEmptyTextStayDistinct() { + assertNull(LogFields.text(null)); + assertEquals("", LogFields.text("")); + } + + @Test + void consecutiveLineBreaksAreEscapedWithoutTrimmingOrCollapsing() { + assertEquals(" \\r\\n\\n ", LogFields.text(" \r\n\n ")); + } + + @Test + void roleListsPreserveOrdinaryOrderNullElementsAndEmptyLists() { + assertNull(LogFields.values(null)); + assertEquals("[]", LogFields.values(List.of())); + assertEquals("[reader, null, maker]", LogFields.values(Arrays.asList("reader", null, "maker"))); + } + + @Test + void unavailableRoleIterationIsOpaqueAndCannotInterruptTheCaller() { + String privatePayload = "synthetic private collection payload"; + for (boolean failBeforeFirstElement : List.of(true, false)) { + var values = new java.util.AbstractCollection() { + @Override + public java.util.Iterator iterator() { + if (failBeforeFirstElement) throw new IllegalStateException(privatePayload); + return new java.util.Iterator<>() { + private boolean visited; + @Override + public boolean hasNext() { + if (visited) throw new IllegalStateException(privatePayload); + return true; + } + @Override + public String next() { + visited = true; + return "partial role"; + } + }; + } + @Override + public int size() { return 1; } + @Override + public String toString() { throw new AssertionError("Custom rendering is forbidden"); } + }; + String result = assertDoesNotThrow(() -> LogFields.values(values)); + assertEquals("[unavailable]", result); + assertFalse(result.contains(privatePayload)); + assertFalse(result.contains("partial role")); + } + var vmFailure = new java.util.AbstractCollection() { + @Override + public java.util.Iterator iterator() { throw new InternalError("synthetic VM failure"); } + @Override + public int size() { return 1; } + }; + assertThrows(InternalError.class, () -> LogFields.values(vmFailure)); + } + + @Test + void roleNamesUseTheSameVisibleLineBreakEscapes() { + assertEquals("[reader\\nrole, maker\\u2028role]", LogFields.values(List.of("reader\nrole", "maker\u2028role"))); + } + + @Test + void errorTypeExposesOnlyTheStableFullClassName() { + var error = new IllegalArgumentException("synthetic credential response", new RuntimeException("private cause")); + assertEquals("java.lang.IllegalArgumentException", LogFields.errorType(error)); + assertNull(LogFields.errorType(null)); + assertEquals("synthetic credential response", error.getMessage()); + assertEquals("private cause", error.getCause().getMessage()); + } + + @Test + void actualSlf4jEventKeepsLevelAuditAndMdcWithoutRawErrorPayload() { + Logger logger = (Logger) LoggerFactory.getLogger("LogFieldsTest.owned"); + var appender = new ListAppender(); + appender.setContext(logger.getLoggerContext()); + appender.start(); + Level previousLevel = logger.getLevel(); + boolean previousAdditive = logger.isAdditive(); + Map previousContext = MDC.getCopyOfContextMap(); + logger.setLevel(Level.DEBUG); + logger.setAdditive(false); + logger.addAppender(appender); + try { + MDC.put("correlationId", "correlation-42"); + var error = new IllegalStateException("synthetic private provider response"); + logger.warn("Provider failure user={} roles={} errorType={}", + LogFields.text("user\nname"), LogFields.values(List.of("reader\rrole")), LogFields.errorType(error)); + ILoggingEvent event = appender.list.getFirst(); + assertEquals(Level.WARN, event.getLevel()); + assertEquals("Provider failure user=user\\nname roles=[reader\\rrole] errorType=java.lang.IllegalStateException", event.getFormattedMessage()); + assertEquals("correlation-42", event.getMDCPropertyMap().get("correlationId")); + assertNull(event.getThrowableProxy()); + assertFalse(event.getFormattedMessage().contains("private provider response")); + assertEquals("correlation-42", MDC.get("correlationId")); + var auditId = java.util.UUID.fromString("7ddce240-8657-4f4d-a63d-f1778f739463"); + logger.info("Audit id={} attempts={} user={}", auditId, 3, LogFields.text(null)); + ILoggingEvent audit = appender.list.get(1); + assertSame(auditId, audit.getArgumentArray()[0]); + assertEquals(3, audit.getArgumentArray()[1]); + assertNull(audit.getArgumentArray()[2]); + assertEquals("Audit id=" + auditId + " attempts=3 user=null", audit.getFormattedMessage()); + } finally { + logger.detachAppender(appender); + appender.stop(); + logger.setLevel(previousLevel); + logger.setAdditive(previousAdditive); + if (previousContext == null) MDC.clear(); + else MDC.setContextMap(previousContext); + } + } +} diff --git a/modules/security-idp-aws-cognito/src/main/java/org/fireflyframework/security/idp/cognito/service/CognitoAdminService.java b/modules/security-idp-aws-cognito/src/main/java/org/fireflyframework/security/idp/cognito/service/CognitoAdminService.java index d60d2d581..209641c3a 100644 --- a/modules/security-idp-aws-cognito/src/main/java/org/fireflyframework/security/idp/cognito/service/CognitoAdminService.java +++ b/modules/security-idp-aws-cognito/src/main/java/org/fireflyframework/security/idp/cognito/service/CognitoAdminService.java @@ -16,6 +16,7 @@ package org.fireflyframework.security.idp.cognito.service; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.security.idp.cognito.client.CognitoClientFactory; import org.fireflyframework.security.idp.cognito.properties.CognitoProperties; import org.fireflyframework.security.idp.dtos.*; @@ -54,7 +55,7 @@ public class CognitoAdminService { */ public Mono> createUser(CreateUserRequest request) { return Mono.fromCallable(() -> { - log.info("Creating Cognito user: {}", request.getUsername()); + log.info("Creating Cognito user: {}", LogFields.text(request.getUsername())); CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -109,11 +110,11 @@ public Mono> createUser(CreateUserRequest req .email(request.getEmail()) .build(); - log.info("Successfully created user: {}", request.getUsername()); + log.info("Successfully created user: {}", LogFields.text(request.getUsername())); return ResponseEntity.ok(userResponse); }).onErrorResume(exception -> { - log.error("Failed to create user: {}", request.getUsername(), exception); + log.error("Failed to create user: {} errorType={}", LogFields.text(request.getUsername()), LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -123,7 +124,7 @@ public Mono> createUser(CreateUserRequest req */ public Mono changePassword(org.fireflyframework.security.idp.dtos.ChangePasswordRequest request) { return Mono.fromRunnable(() -> { - log.info("Changing password for user: {}", request.getUserId()); + log.info("Changing password for user: {}", LogFields.text(request.getUserId())); try { CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -137,9 +138,9 @@ public Mono changePassword(org.fireflyframework.security.idp.dtos.ChangePa client.adminSetUserPassword(setPasswordRequest); - log.info("Successfully changed password for user: {}", request.getUserId()); + log.info("Successfully changed password for user: {}", LogFields.text(request.getUserId())); } catch (Exception e) { - log.error("Failed to change password", e); + log.error("Failed to change password errorType={}", LogFields.errorType(e)); throw new RuntimeException("Password change failed", e); } }); @@ -150,7 +151,7 @@ public Mono changePassword(org.fireflyframework.security.idp.dtos.ChangePa */ public Mono resetPassword(String username) { return Mono.fromRunnable(() -> { - log.info("Resetting password for user: {}", username); + log.info("Resetting password for user: {}", LogFields.text(username)); try { CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -162,9 +163,9 @@ public Mono resetPassword(String username) { client.adminResetUserPassword(resetRequest); - log.info("Successfully initiated password reset for user: {}", username); + log.info("Successfully initiated password reset for user: {}", LogFields.text(username)); } catch (Exception e) { - log.error("Failed to reset password", e); + log.error("Failed to reset password errorType={}", LogFields.errorType(e)); throw new RuntimeException("Password reset failed", e); } }); @@ -175,7 +176,7 @@ public Mono resetPassword(String username) { */ public Mono> mfaChallenge(String username) { return Mono.fromCallable(() -> { - log.info("Initiating MFA challenge for user: {}", username); + log.info("Initiating MFA challenge for user: {}", LogFields.text(username)); // Cognito MFA is typically handled during authentication flow // This is a placeholder for custom MFA implementation @@ -206,7 +207,7 @@ public Mono mfaVerify(MfaVerifyRequest request) { */ public Mono>> listSessions(String userId) { return Mono.fromCallable(() -> { - log.info("Listing sessions for user: {}", userId); + log.info("Listing sessions for user: {}", LogFields.text(userId)); CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -228,7 +229,7 @@ public Mono>> listSessions(String userId) { return ResponseEntity.ok(sessions); }).onErrorResume(exception -> { - log.error("Failed to list sessions", exception); + log.error("Failed to list sessions errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.ok(Collections.emptyList())); }); } @@ -238,7 +239,7 @@ public Mono>> listSessions(String userId) { */ public Mono revokeSession(String sessionId) { return Mono.fromRunnable(() -> { - log.info("Revoking session: {}", sessionId); + log.info("Revoking session: {}", LogFields.text(sessionId)); try { CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -251,9 +252,9 @@ public Mono revokeSession(String sessionId) { client.adminForgetDevice(forgetDeviceRequest); - log.info("Successfully revoked session: {}", sessionId); + log.info("Successfully revoked session: {}", LogFields.text(sessionId)); } catch (Exception e) { - log.error("Failed to revoke session", e); + log.error("Failed to revoke session errorType={}", LogFields.errorType(e)); throw new RuntimeException("Session revocation failed", e); } }); @@ -264,7 +265,7 @@ public Mono revokeSession(String sessionId) { */ public Mono>> getRoles(String userId) { return Mono.fromCallable(() -> { - log.info("Getting roles for user: {}", userId); + log.info("Getting roles for user: {}", LogFields.text(userId)); CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -282,7 +283,7 @@ public Mono>> getRoles(String userId) { return ResponseEntity.ok(roles); }).onErrorResume(exception -> { - log.error("Failed to get user roles", exception); + log.error("Failed to get user roles errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.ok(Collections.emptyList())); }); } @@ -292,7 +293,7 @@ public Mono>> getRoles(String userId) { */ public Mono deleteUser(String userId) { return Mono.fromRunnable(() -> { - log.info("Deleting user: {}", userId); + log.info("Deleting user: {}", LogFields.text(userId)); try { CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -304,9 +305,9 @@ public Mono deleteUser(String userId) { client.adminDeleteUser(deleteRequest); - log.info("Successfully deleted user: {}", userId); + log.info("Successfully deleted user: {}", LogFields.text(userId)); } catch (Exception e) { - log.error("Failed to delete user", e); + log.error("Failed to delete user errorType={}", LogFields.errorType(e)); throw new RuntimeException("User deletion failed", e); } }); @@ -317,7 +318,7 @@ public Mono deleteUser(String userId) { */ public Mono> updateUser(UpdateUserRequest request) { return Mono.fromCallable(() -> { - log.info("Updating user: {}", request.getUserId()); + log.info("Updating user: {}", LogFields.text(request.getUserId())); CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -345,11 +346,11 @@ public Mono> updateUser(UpdateUserRequest req .username(request.getUserId()) .build(); - log.info("Successfully updated user: {}", request.getUserId()); + log.info("Successfully updated user: {}", LogFields.text(request.getUserId())); return ResponseEntity.ok(response); }).onErrorResume(exception -> { - log.error("Failed to update user", exception); + log.error("Failed to update user errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -359,7 +360,7 @@ public Mono> updateUser(UpdateUserRequest req */ public Mono> createRoles(CreateRolesRequest request) { return Mono.fromCallable(() -> { - log.info("Creating roles: {}", request.getRoleNames()); + log.info("Creating roles: {}", LogFields.values(request.getRoleNames())); CognitoIdentityProviderClient client = clientFactory.getClient(); List createdRoles = new ArrayList<>(); @@ -376,7 +377,7 @@ public Mono> createRoles(CreateRolesRequest createdRoles.add(roleName); } catch (Exception e) { - log.warn("Failed to create role: {}", roleName, e); + log.warn("Failed to create role: {} errorType={}", LogFields.text(roleName), LogFields.errorType(e)); } } @@ -387,7 +388,7 @@ public Mono> createRoles(CreateRolesRequest return ResponseEntity.ok(response); }).onErrorResume(exception -> { - log.error("Failed to create roles", exception); + log.error("Failed to create roles errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -397,7 +398,7 @@ public Mono> createRoles(CreateRolesRequest */ public Mono> createScope(CreateScopeRequest request) { return Mono.fromCallable(() -> { - log.info("Creating scope: {}", request.getName()); + log.info("Creating scope: {}", LogFields.text(request.getName())); // Cognito uses resource servers for scopes // This is a simplified placeholder @@ -414,7 +415,7 @@ public Mono> createScope(CreateScopeRequest */ public Mono assignRolesToUser(AssignRolesRequest request) { return Mono.fromRunnable(() -> { - log.info("Assigning roles to user: {}", request.getUserId()); + log.info("Assigning roles to user: {}", LogFields.text(request.getUserId())); try { CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -429,9 +430,9 @@ public Mono assignRolesToUser(AssignRolesRequest request) { client.adminAddUserToGroup(addToGroupRequest); } - log.info("Successfully assigned roles to user: {}", request.getUserId()); + log.info("Successfully assigned roles to user: {}", LogFields.text(request.getUserId())); } catch (Exception e) { - log.error("Failed to assign roles", e); + log.error("Failed to assign roles errorType={}", LogFields.errorType(e)); throw new RuntimeException("Role assignment failed", e); } }); @@ -442,7 +443,7 @@ public Mono assignRolesToUser(AssignRolesRequest request) { */ public Mono removeRolesFromUser(AssignRolesRequest request) { return Mono.fromRunnable(() -> { - log.info("Removing roles from user: {}", request.getUserId()); + log.info("Removing roles from user: {}", LogFields.text(request.getUserId())); try { CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -457,9 +458,9 @@ public Mono removeRolesFromUser(AssignRolesRequest request) { client.adminRemoveUserFromGroup(removeFromGroupRequest); } - log.info("Successfully removed roles from user: {}", request.getUserId()); + log.info("Successfully removed roles from user: {}", LogFields.text(request.getUserId())); } catch (Exception e) { - log.error("Failed to remove roles", e); + log.error("Failed to remove roles errorType={}", LogFields.errorType(e)); throw new RuntimeException("Role removal failed", e); } }); diff --git a/modules/security-idp-aws-cognito/src/main/java/org/fireflyframework/security/idp/cognito/service/CognitoUserService.java b/modules/security-idp-aws-cognito/src/main/java/org/fireflyframework/security/idp/cognito/service/CognitoUserService.java index 6b60afcd3..25809d4a6 100644 --- a/modules/security-idp-aws-cognito/src/main/java/org/fireflyframework/security/idp/cognito/service/CognitoUserService.java +++ b/modules/security-idp-aws-cognito/src/main/java/org/fireflyframework/security/idp/cognito/service/CognitoUserService.java @@ -16,6 +16,7 @@ package org.fireflyframework.security.idp.cognito.service; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.security.idp.cognito.client.CognitoClientFactory; import org.fireflyframework.security.idp.cognito.properties.CognitoProperties; import org.fireflyframework.security.idp.cognito.util.CognitoSecretHashCalculator; @@ -57,7 +58,7 @@ public class CognitoUserService { */ public Mono> login(LoginRequest request) { return Mono.>fromCallable(() -> { - log.info("Initiating Cognito login for user: {}", request.getUsername()); + log.info("Initiating Cognito login for user: {}", LogFields.text(request.getUsername())); CognitoIdentityProviderClient client = clientFactory.getClient(); @@ -98,11 +99,11 @@ public Mono> login(LoginRequest request) { .expiresIn(authResult.expiresIn().longValue()) .build(); - log.info("Successfully authenticated user: {}", request.getUsername()); + log.info("Successfully authenticated user: {}", LogFields.text(request.getUsername())); return ResponseEntity.ok(tokenResponse); }).onErrorResume(exception -> { - log.error("Cognito login failed for user: {}", request.getUsername(), exception); + log.error("Cognito login failed for user: {} errorType={}", LogFields.text(request.getUsername()), LogFields.errorType(exception)); if (exception instanceof NotAuthorizedException) { return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); @@ -147,7 +148,7 @@ public Mono> refresh(RefreshRequest request) { return ResponseEntity.ok(tokenResponse); }).onErrorResume(exception -> { - log.error("Token refresh failed", exception); + log.error("Token refresh failed errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); }); } @@ -170,7 +171,7 @@ public Mono logout(LogoutRequest request) { log.info("Successfully logged out user"); } catch (Exception e) { - log.error("Logout failed", e); + log.error("Logout failed errorType={}", LogFields.errorType(e)); throw new RuntimeException("Logout failed", e); } }); @@ -208,7 +209,7 @@ public Mono> introspect(String accessToken return ResponseEntity.ok(introspection); } }).onErrorResume(exception -> { - log.error("Token introspection failed", exception); + log.error("Token introspection failed errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -244,11 +245,11 @@ public Mono> getUserInfo(String accessToken) { .name(attributes.get("name")) .build(); - log.debug("Successfully fetched user info for: {}", getUserResponse.username()); + log.debug("Successfully fetched user info for: {}", LogFields.text(getUserResponse.username())); return ResponseEntity.ok(userInfo); }).onErrorResume(exception -> { - log.error("Failed to fetch user info", exception); + log.error("Failed to fetch user info errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); }); } @@ -278,7 +279,7 @@ public Mono revokeRefreshToken(String refreshToken) { log.info("Successfully revoked refresh token"); } catch (Exception e) { - log.error("Failed to revoke refresh token", e); + log.error("Failed to revoke refresh token errorType={}", LogFields.errorType(e)); throw new RuntimeException("Token revocation failed", e); } }); diff --git a/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/AzureAdAuthService.java b/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/AzureAdAuthService.java index a2be972b2..0249b4fe4 100644 --- a/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/AzureAdAuthService.java +++ b/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/AzureAdAuthService.java @@ -15,6 +15,7 @@ */ package org.fireflyframework.security.idp.azuread.service; +import org.fireflyframework.kernel.logging.LogFields; import com.fasterxml.jackson.databind.JsonNode; import com.microsoft.aad.msal4j.IAuthenticationResult; import com.microsoft.aad.msal4j.MsalException; @@ -92,7 +93,7 @@ private PublicClientApplication getPublicClient() { */ public Mono> login(LoginRequest request) { return Mono.>fromCallable(() -> { - log.info("Initiating Azure AD login for user: {}", request.getUsername()); + log.info("Initiating Azure AD login for user: {}", LogFields.text(request.getUsername())); PublicClientApplication client = getPublicClient(); @@ -121,12 +122,12 @@ public Mono> login(LoginRequest request) { .scope(String.join(" ", scopes)) .build(); - log.info("Successfully authenticated user: {}", request.getUsername()); + log.info("Successfully authenticated user: {}", LogFields.text(request.getUsername())); return ResponseEntity.ok(tokenResponse); }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Azure AD login failed for user: {}", request.getUsername(), exception); + log.error("Azure AD login failed for user: {} errorType={}", LogFields.text(request.getUsername()), LogFields.errorType(exception)); if (exception instanceof MsalException) { return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); @@ -168,7 +169,7 @@ public Mono> refresh(RefreshRequest request) { }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Token refresh failed", exception); + log.error("Token refresh failed errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); }); } @@ -185,12 +186,12 @@ public Mono logout(LogoutRequest request) { graphClient.users().byUserId(userId).revokeSignInSessions().post(); - log.info("Successfully logged out user: {}", userId); + log.info("Successfully logged out user: {}", LogFields.text(userId)); return null; }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Logout failed", exception); + log.error("Logout failed errorType={}", LogFields.errorType(exception)); return Mono.error(new RuntimeException("Logout failed", exception)); }); } @@ -227,7 +228,7 @@ public Mono> introspect(String accessToken }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Token introspection failed", exception); + log.error("Token introspection failed errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -254,12 +255,12 @@ public Mono> getUserInfo(String accessToken) { .name(user.getDisplayName()) .build(); - log.debug("Successfully fetched user info for: {}", userId); + log.debug("Successfully fetched user info for: {}", LogFields.text(userId)); return ResponseEntity.ok(userInfo); }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to fetch user info", exception); + log.error("Failed to fetch user info errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); }); } @@ -282,7 +283,7 @@ public Mono revokeRefreshToken(String refreshToken) { String userId = AzureAdTokenParser.extractUserId(refreshToken); GraphServiceClient graphClient = graphClientFactory.getClient(); graphClient.users().byUserId(userId).revokeSignInSessions().post(); - log.info("Successfully revoked sessions for user: {}", userId); + log.info("Successfully revoked sessions for user: {}", LogFields.text(userId)); } catch (Exception e) { // Refresh token is opaque — cannot extract user ID. // Azure AD does not support revoking a single refresh token via Graph API. diff --git a/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/B2CAdminService.java b/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/B2CAdminService.java index 406929018..71ea6fd41 100644 --- a/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/B2CAdminService.java +++ b/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/B2CAdminService.java @@ -15,6 +15,7 @@ */ package org.fireflyframework.security.idp.azuread.service; +import org.fireflyframework.kernel.logging.LogFields; import com.microsoft.graph.models.ObjectIdentity; import com.microsoft.graph.models.PasswordProfile; import com.microsoft.graph.models.User; @@ -55,7 +56,7 @@ public B2CAdminService(GraphClientFactory graphClientFactory, AzureAdProperties @Override public Mono> createUser(CreateUserRequest request) { return Mono.>fromCallable(() -> { - log.info("Creating B2C user: {}", request.getUsername()); + log.info("Creating B2C user: {}", LogFields.text(request.getUsername())); GraphServiceClient graphClient = graphClientFactory.getClient(); String b2cDomain = properties.getB2c().getDomain(); @@ -104,12 +105,12 @@ public Mono> createUser(CreateUserRequest req .createdAt(Instant.now()) .build(); - log.info("Successfully created B2C user: {}", request.getUsername()); + log.info("Successfully created B2C user: {}", LogFields.text(request.getUsername())); return ResponseEntity.ok(response); }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to create B2C user: {}", request.getUsername(), exception); + log.error("Failed to create B2C user: {} errorType={}", LogFields.text(request.getUsername()), LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -117,7 +118,7 @@ public Mono> createUser(CreateUserRequest req @Override public Mono resetPassword(String username) { return Mono.fromCallable(() -> { - log.info("Resetting password for B2C user: {}", username); + log.info("Resetting password for B2C user: {}", LogFields.text(username)); GraphServiceClient graphClient = graphClientFactory.getClient(); @@ -129,12 +130,12 @@ public Mono resetPassword(String username) { graphClient.users().byUserId(username).patch(user); - log.info("Successfully initiated password reset for B2C user: {} — user should use self-service flow", username); + log.info("Successfully initiated password reset for B2C user: {} — user should use self-service flow", LogFields.text(username)); return null; }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to reset B2C password", exception); + log.error("Failed to reset B2C password errorType={}", LogFields.errorType(exception)); return Mono.error(new RuntimeException("B2C password reset failed", exception)); }); } diff --git a/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/EntraIdAdminService.java b/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/EntraIdAdminService.java index befcc64e8..a6d6861ae 100644 --- a/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/EntraIdAdminService.java +++ b/modules/security-idp-azure-ad/src/main/java/org/fireflyframework/security/idp/azuread/service/EntraIdAdminService.java @@ -15,6 +15,7 @@ */ package org.fireflyframework.security.idp.azuread.service; +import org.fireflyframework.kernel.logging.LogFields; import com.microsoft.graph.models.AppRole; import com.microsoft.graph.models.AppRoleAssignment; import com.microsoft.graph.models.AppRoleAssignmentCollectionResponse; @@ -72,7 +73,7 @@ public class EntraIdAdminService implements AzureAdAdminService { @Override public Mono> createUser(CreateUserRequest request) { return Mono.>fromCallable(() -> { - log.info("Creating Entra ID user: {}", request.getUsername()); + log.info("Creating Entra ID user: {}", LogFields.text(request.getUsername())); GraphServiceClient graphClient = graphClientFactory.getClient(); @@ -103,12 +104,12 @@ public Mono> createUser(CreateUserRequest req .createdAt(Instant.now()) .build(); - log.info("Successfully created user: {}", request.getUsername()); + log.info("Successfully created user: {}", LogFields.text(request.getUsername())); return ResponseEntity.ok(response); }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to create user: {}", request.getUsername(), exception); + log.error("Failed to create user: {} errorType={}", LogFields.text(request.getUsername()), LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -116,7 +117,7 @@ public Mono> createUser(CreateUserRequest req @Override public Mono changePassword(ChangePasswordRequest request) { return Mono.fromCallable(() -> { - log.info("Changing password for user: {}", request.getUserId()); + log.info("Changing password for user: {}", LogFields.text(request.getUserId())); GraphServiceClient graphClient = graphClientFactory.getClient(); @@ -128,12 +129,12 @@ public Mono changePassword(ChangePasswordRequest request) { graphClient.users().byUserId(request.getUserId()).patch(user); - log.info("Successfully changed password for user: {}", request.getUserId()); + log.info("Successfully changed password for user: {}", LogFields.text(request.getUserId())); return null; }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to change password", exception); + log.error("Failed to change password errorType={}", LogFields.errorType(exception)); return Mono.error(new RuntimeException("Password change failed", exception)); }); } @@ -141,7 +142,7 @@ public Mono changePassword(ChangePasswordRequest request) { @Override public Mono resetPassword(String username) { return Mono.fromCallable(() -> { - log.info("Resetting password for user: {}", username); + log.info("Resetting password for user: {}", LogFields.text(username)); GraphServiceClient graphClient = graphClientFactory.getClient(); @@ -153,12 +154,12 @@ public Mono resetPassword(String username) { graphClient.users().byUserId(username).patch(user); - log.info("Successfully initiated password reset for user: {}", username); + log.info("Successfully initiated password reset for user: {}", LogFields.text(username)); return null; }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to reset password", exception); + log.error("Failed to reset password errorType={}", LogFields.errorType(exception)); return Mono.error(new RuntimeException("Password reset failed", exception)); }); } @@ -166,7 +167,7 @@ public Mono resetPassword(String username) { @Override public Mono> mfaChallenge(String username) { return Mono.>fromCallable(() -> { - log.info("MFA challenge requested for user: {} — Azure MFA is managed by Conditional Access policies", username); + log.info("MFA challenge requested for user: {} — Azure MFA is managed by Conditional Access policies", LogFields.text(username)); // Azure AD MFA is managed by Conditional Access policies, not programmatically MfaChallengeResponse response = MfaChallengeResponse.builder() @@ -192,7 +193,7 @@ public Mono mfaVerify(MfaVerifyRequest request) { @Override public Mono>> listSessions(String userId) { return Mono.>>fromCallable(() -> { - log.info("Listing sessions for user: {}", userId); + log.info("Listing sessions for user: {}", LogFields.text(userId)); GraphServiceClient graphClient = graphClientFactory.getClient(); @@ -215,7 +216,7 @@ public Mono>> listSessions(String userId) { }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to list sessions", exception); + log.error("Failed to list sessions errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.ok(Collections.emptyList())); }); } @@ -223,17 +224,17 @@ public Mono>> listSessions(String userId) { @Override public Mono revokeSession(String sessionId) { return Mono.fromCallable(() -> { - log.info("Revoking session for user: {}", sessionId); + log.info("Revoking session for user: {}", LogFields.text(sessionId)); GraphServiceClient graphClient = graphClientFactory.getClient(); graphClient.users().byUserId(sessionId).revokeSignInSessions().post(); - log.info("Successfully revoked session: {}", sessionId); + log.info("Successfully revoked session: {}", LogFields.text(sessionId)); return null; }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to revoke session", exception); + log.error("Failed to revoke session errorType={}", LogFields.errorType(exception)); return Mono.error(new RuntimeException("Session revocation failed", exception)); }); } @@ -241,7 +242,7 @@ public Mono revokeSession(String sessionId) { @Override public Mono>> getRoles(String userId) { return Mono.>>fromCallable(() -> { - log.info("Getting roles for user: {}", userId); + log.info("Getting roles for user: {}", LogFields.text(userId)); String strategy = properties.getRoleMapping().getStrategy(); List roles = new ArrayList<>(); @@ -257,7 +258,7 @@ public Mono>> getRoles(String userId) { }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to get user roles", exception); + log.error("Failed to get user roles errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.ok(Collections.emptyList())); }); } @@ -265,17 +266,17 @@ public Mono>> getRoles(String userId) { @Override public Mono deleteUser(String userId) { return Mono.fromCallable(() -> { - log.info("Deleting user: {}", userId); + log.info("Deleting user: {}", LogFields.text(userId)); GraphServiceClient graphClient = graphClientFactory.getClient(); graphClient.users().byUserId(userId).delete(); - log.info("Successfully deleted user: {}", userId); + log.info("Successfully deleted user: {}", LogFields.text(userId)); return null; }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to delete user", exception); + log.error("Failed to delete user errorType={}", LogFields.errorType(exception)); return Mono.error(new RuntimeException("User deletion failed", exception)); }); } @@ -283,7 +284,7 @@ public Mono deleteUser(String userId) { @Override public Mono> updateUser(UpdateUserRequest request) { return Mono.>fromCallable(() -> { - log.info("Updating user: {}", request.getUserId()); + log.info("Updating user: {}", LogFields.text(request.getUserId())); GraphServiceClient graphClient = graphClientFactory.getClient(); @@ -310,12 +311,12 @@ public Mono> updateUser(UpdateUserRequest req .updatedAt(Instant.now()) .build(); - log.info("Successfully updated user: {}", request.getUserId()); + log.info("Successfully updated user: {}", LogFields.text(request.getUserId())); return ResponseEntity.ok(response); }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to update user", exception); + log.error("Failed to update user errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -323,7 +324,7 @@ public Mono> updateUser(UpdateUserRequest req @Override public Mono> createRoles(CreateRolesRequest request) { return Mono.>fromCallable(() -> { - log.info("Creating roles: {}", request.getRoleNames()); + log.info("Creating roles: {}", LogFields.values(request.getRoleNames())); GraphServiceClient graphClient = graphClientFactory.getClient(); List createdRoles = new ArrayList<>(); @@ -343,7 +344,7 @@ public Mono> createRoles(CreateRolesRequest createdRoles.add(roleName); } catch (Exception e) { - log.warn("Failed to create role: {}", roleName, e); + log.warn("Failed to create role: {} errorType={}", LogFields.text(roleName), LogFields.errorType(e)); } } @@ -355,7 +356,7 @@ public Mono> createRoles(CreateRolesRequest }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to create roles", exception); + log.error("Failed to create roles errorType={}", LogFields.errorType(exception)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } @@ -363,7 +364,7 @@ public Mono> createRoles(CreateRolesRequest @Override public Mono> createScope(CreateScopeRequest request) { return Mono.>fromCallable(() -> { - log.info("Creating scope: {} — Scopes are configured in Azure AD app registration", request.getName()); + log.info("Creating scope: {} — Scopes are configured in Azure AD app registration", LogFields.text(request.getName())); // Scopes in Azure AD are configured in the app registration, not programmatically CreateScopeResponse response = CreateScopeResponse.builder() @@ -379,7 +380,7 @@ public Mono> createScope(CreateScopeRequest @Override public Mono assignRolesToUser(AssignRolesRequest request) { return Mono.fromCallable(() -> { - log.info("Assigning roles to user: {}", request.getUserId()); + log.info("Assigning roles to user: {}", LogFields.text(request.getUserId())); GraphServiceClient graphClient = graphClientFactory.getClient(); @@ -391,19 +392,19 @@ public Mono assignRolesToUser(AssignRolesRequest request) { referenceCreate.setOdataId("https://graph.microsoft.com/v1.0/directoryObjects/" + request.getUserId()); graphClient.groups().byGroupId(groupId).members().ref().post(referenceCreate); } else { - log.warn("Group not found for role: {}", roleName); + log.warn("Group not found for role: {}", LogFields.text(roleName)); } } catch (Exception e) { - log.warn("Failed to assign role {} to user {}", roleName, request.getUserId(), e); + log.warn("Failed to assign role {} to user {} errorType={}", LogFields.text(roleName), LogFields.text(request.getUserId()), LogFields.errorType(e)); } } - log.info("Successfully assigned roles to user: {}", request.getUserId()); + log.info("Successfully assigned roles to user: {}", LogFields.text(request.getUserId())); return null; }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to assign roles", exception); + log.error("Failed to assign roles errorType={}", LogFields.errorType(exception)); return Mono.error(new RuntimeException("Role assignment failed", exception)); }); } @@ -411,7 +412,7 @@ public Mono assignRolesToUser(AssignRolesRequest request) { @Override public Mono removeRolesFromUser(AssignRolesRequest request) { return Mono.fromCallable(() -> { - log.info("Removing roles from user: {}", request.getUserId()); + log.info("Removing roles from user: {}", LogFields.text(request.getUserId())); GraphServiceClient graphClient = graphClientFactory.getClient(); @@ -422,19 +423,19 @@ public Mono removeRolesFromUser(AssignRolesRequest request) { graphClient.groups().byGroupId(groupId) .members().byDirectoryObjectId(request.getUserId()).ref().delete(); } else { - log.warn("Group not found for role: {}", roleName); + log.warn("Group not found for role: {}", LogFields.text(roleName)); } } catch (Exception e) { - log.warn("Failed to remove role {} from user {}", roleName, request.getUserId(), e); + log.warn("Failed to remove role {} from user {} errorType={}", LogFields.text(roleName), LogFields.text(request.getUserId()), LogFields.errorType(e)); } } - log.info("Successfully removed roles from user: {}", request.getUserId()); + log.info("Successfully removed roles from user: {}", LogFields.text(request.getUserId())); return null; }).subscribeOn(Schedulers.boundedElastic()) .onErrorResume(exception -> { - log.error("Failed to remove roles", exception); + log.error("Failed to remove roles errorType={}", LogFields.errorType(exception)); return Mono.error(new RuntimeException("Role removal failed", exception)); }); } @@ -487,7 +488,7 @@ private List getAppRoles(String userId) { } } } catch (Exception e) { - log.warn("Failed to resolve app roles for resource: {}", entry.getKey(), e); + log.warn("Failed to resolve app roles for resource: {} errorType={}", LogFields.text(entry.getKey()), LogFields.errorType(e)); } } return roleNames; diff --git a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/adapter/InternalDbIdpAdapter.java b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/adapter/InternalDbIdpAdapter.java index c2f9c43fb..d08b4ba38 100644 --- a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/adapter/InternalDbIdpAdapter.java +++ b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/adapter/InternalDbIdpAdapter.java @@ -16,6 +16,7 @@ package org.fireflyframework.security.idp.internaldb.adapter; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.security.idp.adapter.IdpAdapter; import org.fireflyframework.security.idp.dtos.*; import org.fireflyframework.security.idp.internaldb.repository.SessionRepository; @@ -68,12 +69,12 @@ public class InternalDbIdpAdapter implements IdpAdapter { @Override public Mono> login(LoginRequest request) { - log.debug("Login request for user: {}", request.getUsername()); + log.debug("Login request for user: {}", LogFields.text(request.getUsername())); return authenticationService.authenticate(request.getUsername(), request.getPassword()) .map(tokenResponse -> ResponseEntity.ok(tokenResponse)) .onErrorResume(e -> { - log.error("Login failed for user {}: {}", request.getUsername(), e.getMessage()); + log.error("Login failed for user {}: errorType={}", LogFields.text(request.getUsername()), LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(TokenResponse.builder().build())); }); @@ -86,7 +87,7 @@ public Mono> refresh(RefreshRequest request) { return authenticationService.refreshToken(request.getRefreshToken()) .map(tokenResponse -> ResponseEntity.ok(tokenResponse)) .onErrorResume(e -> { - log.error("Token refresh failed: {}", e.getMessage()); + log.error("Token refresh failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(TokenResponse.builder().build())); }); @@ -98,7 +99,7 @@ public Mono logout(LogoutRequest request) { return authenticationService.logout(request.getAccessToken(), request.getRefreshToken()) .onErrorResume(e -> { - log.error("Logout failed: {}", e.getMessage()); + log.error("Logout failed: errorType={}", LogFields.errorType(e)); return Mono.empty(); }); } @@ -135,7 +136,7 @@ public Mono> introspect(String accessToken return Mono.just(ResponseEntity.ok(response)); } catch (Exception e) { - log.error("Token introspection failed: {}", e.getMessage()); + log.error("Token introspection failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.ok(IntrospectionResponse.builder() .active(false) .build())); @@ -179,7 +180,7 @@ public Mono> getUserInfo(String accessToken) { })); } catch (Exception e) { - log.error("Get user info failed: {}", e.getMessage()); + log.error("Get user info failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(UserInfoResponse.builder().build())); } @@ -188,7 +189,7 @@ public Mono> getUserInfo(String accessToken) { @Override public Mono> createUser(CreateUserRequest request) { - log.debug("Create user request: {}", request.getUsername()); + log.debug("Create user request: {}", LogFields.text(request.getUsername())); return userManagementService.createUser(request) .map(user -> { @@ -201,7 +202,7 @@ public Mono> createUser(CreateUserRequest req return ResponseEntity.status(HttpStatus.CREATED).body(response); }) .onErrorResume(e -> { - log.error("Create user failed: {}", e.getMessage()); + log.error("Create user failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(CreateUserResponse.builder().build())); }); @@ -209,7 +210,7 @@ public Mono> createUser(CreateUserRequest req @Override public Mono changePassword(ChangePasswordRequest request) { - log.debug("Change password request for user: {}", request.getUserId()); + log.debug("Change password request for user: {}", LogFields.text(request.getUserId())); if (request.getUserId() == null || request.getUserId().isEmpty()) { return Mono.error(new IllegalArgumentException("User ID is required")); @@ -218,26 +219,26 @@ public Mono changePassword(ChangePasswordRequest request) { UUID userId = UUID.fromString(request.getUserId()); return userManagementService.changePassword(userId, request.getOldPassword(), request.getNewPassword()) .onErrorResume(e -> { - log.error("Change password failed: {}", e.getMessage()); + log.error("Change password failed: errorType={}", LogFields.errorType(e)); return Mono.error(e); }); } @Override public Mono resetPassword(String username) { - log.debug("Reset password request for user: {}", username); + log.debug("Reset password request for user: {}", LogFields.text(username)); return passwordResetService.initiateReset(username) - .doOnSuccess(v -> log.info("Password reset initiated for user: {}", username)) + .doOnSuccess(v -> log.info("Password reset initiated for user: {}", LogFields.text(username))) .onErrorResume(e -> { - log.error("Reset password failed for user {}: {}", username, e.getMessage()); + log.error("Reset password failed for user {}: errorType={}", LogFields.text(username), LogFields.errorType(e)); return Mono.error(e); }); } @Override public Mono> mfaChallenge(String username) { - log.debug("MFA challenge request for user: {}", username); + log.debug("MFA challenge request for user: {}", LogFields.text(username)); return mfaService.isMfaEnabled(username) .flatMap(enabled -> { @@ -261,7 +262,7 @@ public Mono> mfaChallenge(String username) @Override public Mono mfaVerify(MfaVerifyRequest request) { - log.debug("MFA verify request for user: {}", request.getUserId()); + log.debug("MFA verify request for user: {}", LogFields.text(request.getUserId())); if (request.getUserId() == null || request.getCode() == null) { return Mono.error(new IllegalArgumentException("User ID and code are required")); @@ -283,14 +284,14 @@ public Mono revokeRefreshToken(String refreshToken) { return authenticationService.revokeRefreshToken(refreshToken) .onErrorResume(e -> { - log.error("Revoke refresh token failed: {}", e.getMessage()); + log.error("Revoke refresh token failed: errorType={}", LogFields.errorType(e)); return Mono.error(e); }); } @Override public Mono>> listSessions(String userId) { - log.debug("List sessions request for user: {}", userId); + log.debug("List sessions request for user: {}", LogFields.text(userId)); if (userId == null || userId.isEmpty()) { return Mono.just(ResponseEntity.badRequest().body(List.of())); @@ -310,7 +311,7 @@ public Mono>> listSessions(String userId) { .collectList() .map(sessions -> ResponseEntity.ok(sessions)) .onErrorResume(e -> { - log.error("List sessions failed: {}", e.getMessage()); + log.error("List sessions failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(List.of())); }); @@ -318,7 +319,7 @@ public Mono>> listSessions(String userId) { @Override public Mono revokeSession(String sessionId) { - log.debug("Revoke session request: {}", sessionId); + log.debug("Revoke session request: {}", LogFields.text(sessionId)); if (sessionId == null || sessionId.isEmpty()) { return Mono.error(new IllegalArgumentException("Session ID is required")); @@ -332,14 +333,14 @@ public Mono revokeSession(String sessionId) { return sessionRepository.save(session).then(); }) .onErrorResume(e -> { - log.error("Revoke session failed: {}", e.getMessage()); + log.error("Revoke session failed: errorType={}", LogFields.errorType(e)); return Mono.error(e); }); } @Override public Mono>> getRoles(String userId) { - log.debug("Get roles request for user: {}", userId); + log.debug("Get roles request for user: {}", LogFields.text(userId)); if (userId == null || userId.isEmpty()) { return Mono.just(ResponseEntity.badRequest().body(List.of())); @@ -351,7 +352,7 @@ public Mono>> getRoles(String userId) { .collectList() .map(roles -> ResponseEntity.ok(roles)) .onErrorResume(e -> { - log.error("Get roles failed: {}", e.getMessage()); + log.error("Get roles failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(List.of())); }); @@ -359,7 +360,7 @@ public Mono>> getRoles(String userId) { @Override public Mono deleteUser(String userId) { - log.debug("Delete user request: {}", userId); + log.debug("Delete user request: {}", LogFields.text(userId)); if (userId == null || userId.isEmpty()) { return Mono.error(new IllegalArgumentException("User ID is required")); @@ -369,14 +370,14 @@ public Mono deleteUser(String userId) { return userManagementService.deleteUser(userUuid) .onErrorResume(e -> { - log.error("Delete user failed: {}", e.getMessage()); + log.error("Delete user failed: errorType={}", LogFields.errorType(e)); return Mono.error(e); }); } @Override public Mono> updateUser(UpdateUserRequest request) { - log.debug("Update user request: {}", request.getUserId()); + log.debug("Update user request: {}", LogFields.text(request.getUserId())); return userManagementService.updateUser(request) .map(user -> { @@ -389,7 +390,7 @@ public Mono> updateUser(UpdateUserRequest req return ResponseEntity.ok(response); }) .onErrorResume(e -> { - log.error("Update user failed: {}", e.getMessage()); + log.error("Update user failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(UpdateUserResponse.builder().build())); }); @@ -397,7 +398,7 @@ public Mono> updateUser(UpdateUserRequest req @Override public Mono> createRoles(CreateRolesRequest request) { - log.debug("Create roles request: {}", request.getRoleNames()); + log.debug("Create roles request: {}", LogFields.values(request.getRoleNames())); return roleService.createRoles(request.getRoleNames()) .map(role -> role.getName()) @@ -409,7 +410,7 @@ public Mono> createRoles(CreateRolesRequest return ResponseEntity.status(HttpStatus.CREATED).body(response); }) .onErrorResume(e -> { - log.error("Create roles failed: {}", e.getMessage()); + log.error("Create roles failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(CreateRolesResponse.builder().build())); }); @@ -418,7 +419,7 @@ public Mono> createRoles(CreateRolesRequest @Override public Mono> createScope(CreateScopeRequest request) { log.warn("InternalDbIdpAdapter does not support scopes natively; scope '{}' will be stored as a role. " + - "Consider using KeycloakIdpAdapter for full OAuth2 scope support.", request.getName()); + "Consider using KeycloakIdpAdapter for full OAuth2 scope support.", LogFields.text(request.getName())); return roleService.createRole(request.getName(), request.getDescription()) .map(role -> { @@ -430,7 +431,7 @@ public Mono> createScope(CreateScopeRequest return ResponseEntity.status(HttpStatus.CREATED).body(response); }) .onErrorResume(e -> { - log.error("Create scope failed: {}", e.getMessage()); + log.error("Create scope failed: errorType={}", LogFields.errorType(e)); return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(CreateScopeResponse.builder().build())); }); @@ -438,7 +439,7 @@ public Mono> createScope(CreateScopeRequest @Override public Mono assignRolesToUser(AssignRolesRequest request) { - log.debug("Assign roles to user request: {}", request.getUserId()); + log.debug("Assign roles to user request: {}", LogFields.text(request.getUserId())); if (request.getUserId() == null || request.getUserId().isEmpty()) { return Mono.error(new IllegalArgumentException("User ID is required")); @@ -448,14 +449,14 @@ public Mono assignRolesToUser(AssignRolesRequest request) { return roleService.assignRolesToUser(userId, request.getRoleNames()) .onErrorResume(e -> { - log.error("Assign roles failed: {}", e.getMessage()); + log.error("Assign roles failed: errorType={}", LogFields.errorType(e)); return Mono.error(e); }); } @Override public Mono removeRolesFromUser(AssignRolesRequest request) { - log.debug("Remove roles from user request: {}", request.getUserId()); + log.debug("Remove roles from user request: {}", LogFields.text(request.getUserId())); if (request.getUserId() == null || request.getUserId().isEmpty()) { return Mono.error(new IllegalArgumentException("User ID is required")); @@ -465,7 +466,7 @@ public Mono removeRolesFromUser(AssignRolesRequest request) { return roleService.removeRolesFromUser(userId, request.getRoleNames()) .onErrorResume(e -> { - log.error("Remove roles failed: {}", e.getMessage()); + log.error("Remove roles failed: errorType={}", LogFields.errorType(e)); return Mono.error(e); }); } diff --git a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/AuthenticationService.java b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/AuthenticationService.java index a3f46bfe1..6bc183659 100644 --- a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/AuthenticationService.java +++ b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/AuthenticationService.java @@ -16,6 +16,7 @@ package org.fireflyframework.security.idp.internaldb.service; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.security.idp.dtos.TokenResponse; import org.fireflyframework.security.idp.internaldb.config.InternalDbProperties; import org.fireflyframework.security.idp.internaldb.domain.RefreshToken; @@ -67,7 +68,7 @@ public class AuthenticationService { * @return a Mono emitting the token response */ public Mono authenticate(String username, String password) { - log.debug("Authenticating user: {}", username); + log.debug("Authenticating user: {}", LogFields.text(username)); return userRepository.findByUsername(username) .doOnNext(user -> user.markAsNotNew()) @@ -84,7 +85,7 @@ public Mono authenticate(String username, String password) { // Check automatic lockout if (user.getLockedUntil() != null && user.getLockedUntil().isAfter(LocalDateTime.now())) { - log.warn("Login attempt for locked user: {} (locked until {})", username, user.getLockedUntil()); + log.warn("Login attempt for locked user: {} (locked until {})", LogFields.text(username), user.getLockedUntil()); return Mono.error(new RuntimeException("Account is temporarily locked. Try again later.")); } @@ -128,7 +129,7 @@ private Mono handleFailedLogin(User user) { LocalDateTime lockUntil = LocalDateTime.now().plus(properties.getLockout().getLockoutDuration()); user.setLockedUntil(lockUntil); log.warn("Account locked for user: {} after {} failed attempts (locked until {})", - user.getUsername(), attempts, lockUntil); + LogFields.text(user.getUsername()), attempts, lockUntil); } return userRepository.save(user) @@ -170,7 +171,7 @@ public Mono refreshToken(String refreshTokenString) { .flatMap(this::generateTokens); } catch (Exception e) { - log.error("Failed to refresh token", e); + log.error("Failed to refresh token errorType={}", LogFields.errorType(e)); return Mono.error(new RuntimeException("Invalid refresh token")); } } @@ -218,7 +219,7 @@ public Mono logout(String accessToken, String refreshTokenString) { return revokeSession.then(revokeRefreshToken); } catch (Exception e) { - log.error("Failed to logout", e); + log.error("Failed to logout errorType={}", LogFields.errorType(e)); return Mono.error(new RuntimeException("Logout failed")); } } @@ -305,7 +306,7 @@ public Mono validateAccessToken(String accessToken) { .defaultIfEmpty(false); } catch (Exception e) { - log.error("Failed to validate access token", e); + log.error("Failed to validate access token errorType={}", LogFields.errorType(e)); return Mono.just(false); } } @@ -334,7 +335,7 @@ public Mono revokeRefreshToken(String refreshTokenString) { .then(); } catch (Exception e) { - log.error("Failed to revoke refresh token", e); + log.error("Failed to revoke refresh token errorType={}", LogFields.errorType(e)); return Mono.error(new RuntimeException("Failed to revoke refresh token")); } } diff --git a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/PasswordResetService.java b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/PasswordResetService.java index a4475550e..e310db115 100644 --- a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/PasswordResetService.java +++ b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/PasswordResetService.java @@ -16,6 +16,7 @@ package org.fireflyframework.security.idp.internaldb.service; +import org.fireflyframework.kernel.logging.LogFields; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.fireflyframework.security.idp.internaldb.config.InternalDbProperties; @@ -68,7 +69,7 @@ public Mono initiateReset(String username) { return userRepository.findByUsername(username) .switchIfEmpty(Mono.defer(() -> { // Don't reveal whether the user exists — log and return silently - log.debug("Password reset requested for non-existent user: {}", username); + log.debug("Password reset requested for non-existent user: {}", LogFields.text(username)); return Mono.empty(); })) .flatMap(user -> { @@ -77,7 +78,7 @@ public Mono initiateReset(String username) { return tokenRepository.countByUserIdAndCreatedAtAfter(user.getId(), oneHourAgo) .flatMap(count -> { if (count >= MAX_RESETS_PER_HOUR) { - log.warn("Rate limit exceeded for password reset: user={}", username); + log.warn("Rate limit exceeded for password reset: user={}", LogFields.text(username)); return Mono.error(new IllegalStateException( "Too many password reset requests. Please try again later.")); } @@ -101,7 +102,7 @@ public Mono initiateReset(String username) { .doOnSuccess(saved -> { // SECURITY: Log the reset token ID for audit, NEVER the raw token log.info("Password reset token created: tokenId={}, user={}, expiresAt={}", - saved.getId(), username, saved.getExpiresAt()); + saved.getId(), LogFields.text(username), saved.getExpiresAt()); // TODO: Wire email notification module to send reset link // emailProvider.sendPasswordResetEmail(user.getEmail(), rawToken); }) diff --git a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/RoleService.java b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/RoleService.java index 975fdfef6..b419a9729 100644 --- a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/RoleService.java +++ b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/RoleService.java @@ -16,6 +16,7 @@ package org.fireflyframework.security.idp.internaldb.service; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.security.idp.internaldb.domain.Role; import org.fireflyframework.security.idp.internaldb.domain.UserRole; import org.fireflyframework.security.idp.internaldb.repository.RoleRepository; @@ -47,7 +48,7 @@ public class RoleService { * @return a Mono emitting the created role */ public Mono createRole(String name, String description) { - log.debug("Creating role: {}", name); + log.debug("Creating role: {}", LogFields.text(name)); return roleRepository.existsByName(name) .flatMap(exists -> { @@ -79,7 +80,10 @@ public Flux createRoles(List roleNames) { return Flux.fromIterable(roleNames) .flatMap(roleName -> createRole(roleName, null)) .onErrorContinue((error, item) -> - log.warn("Failed to create role {}: {}", item, error.getMessage())); + log.warn("Failed to create role {}: errorType={}", + item instanceof String roleItem ? LogFields.text(roleItem) + : item == null ? null : item.getClass().getName(), + LogFields.errorType(error))); } /** @@ -121,7 +125,7 @@ public Flux getUserRoles(UUID userId) { * @return a Mono signaling completion */ public Mono assignRoleToUser(UUID userId, String roleName) { - log.debug("Assigning role {} to user {}", roleName, userId); + log.debug("Assigning role {} to user {}", LogFields.text(roleName), userId); return roleRepository.findByName(roleName) .switchIfEmpty(Mono.error(new RuntimeException("Role not found: " + roleName))) @@ -131,7 +135,7 @@ public Mono assignRoleToUser(UUID userId, String roleName) { .hasElement() .flatMap(exists -> { if (exists) { - log.debug("Role {} already assigned to user {}", roleName, userId); + log.debug("Role {} already assigned to user {}", LogFields.text(roleName), userId); return Mono.empty(); } @@ -170,7 +174,7 @@ public Mono assignRolesToUser(UUID userId, List roleNames) { * @return a Mono signaling completion */ public Mono removeRoleFromUser(UUID userId, String roleName) { - log.debug("Removing role {} from user {}", roleName, userId); + log.debug("Removing role {} from user {}", LogFields.text(roleName), userId); return roleRepository.findByName(roleName) .switchIfEmpty(Mono.error(new RuntimeException("Role not found: " + roleName))) @@ -198,7 +202,7 @@ public Mono removeRolesFromUser(UUID userId, List roleNames) { * @return a Mono signaling completion */ public Mono deleteRole(String roleName) { - log.debug("Deleting role: {}", roleName); + log.debug("Deleting role: {}", LogFields.text(roleName)); return roleRepository.findByName(roleName) .switchIfEmpty(Mono.error(new RuntimeException("Role not found: " + roleName))) diff --git a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/UserManagementService.java b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/UserManagementService.java index 02127e9c6..76e7f31db 100644 --- a/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/UserManagementService.java +++ b/modules/security-idp-internal-db/src/main/java/org/fireflyframework/security/idp/internaldb/service/UserManagementService.java @@ -16,6 +16,7 @@ package org.fireflyframework.security.idp.internaldb.service; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.security.idp.dtos.CreateUserRequest; import org.fireflyframework.security.idp.dtos.UpdateUserRequest; import org.fireflyframework.security.idp.internaldb.domain.User; @@ -52,7 +53,7 @@ public class UserManagementService { * @return a Mono emitting the created user */ public Mono createUser(CreateUserRequest request) { - log.debug("Creating user: {}", request.getUsername()); + log.debug("Creating user: {}", LogFields.text(request.getUsername())); return userRepository.existsByUsername(request.getUsername()) .flatMap(exists -> { @@ -127,7 +128,7 @@ public Mono getUserByUsername(String username) { * @return a Mono emitting the updated user */ public Mono updateUser(UpdateUserRequest request) { - log.debug("Updating user: {}", request.getUserId()); + log.debug("Updating user: {}", LogFields.text(request.getUserId())); UUID userId = UUID.fromString(request.getUserId()); @@ -209,7 +210,7 @@ public Mono changePassword(UUID userId, String oldPassword, String newPass * @return a Mono signaling completion */ public Mono resetPassword(String username, String newPassword) { - log.debug("Resetting password for user: {}", username); + log.debug("Resetting password for user: {}", LogFields.text(username)); return userRepository.findByUsername(username) .doOnNext(user -> user.markAsNotNew()) diff --git a/modules/security-idp-keycloak/src/main/java/org/fireflyframework/security/idp/adapter/service/impl/IdpAdminServiceImpl.java b/modules/security-idp-keycloak/src/main/java/org/fireflyframework/security/idp/adapter/service/impl/IdpAdminServiceImpl.java index 96cceb8ce..78596206c 100644 --- a/modules/security-idp-keycloak/src/main/java/org/fireflyframework/security/idp/adapter/service/impl/IdpAdminServiceImpl.java +++ b/modules/security-idp-keycloak/src/main/java/org/fireflyframework/security/idp/adapter/service/impl/IdpAdminServiceImpl.java @@ -17,6 +17,7 @@ package org.fireflyframework.security.idp.adapter.service.impl; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.security.idp.adapter.exception.KeycloakExceptionHandler; import org.fireflyframework.security.idp.adapter.keycloak.KeycloakClientFactory; import org.fireflyframework.security.idp.adapter.service.IdpAdminService; @@ -56,7 +57,7 @@ public Mono> createUser(CreateUserRequest req return Mono.fromCallable(() -> performCreateUser(request)) .map(ResponseEntity::ok) .onErrorResume(throwable -> { - log.error("Error creating user: {}", request.getUsername(), throwable); + log.error("Error creating user: {} errorType={}", LogFields.text(request.getUsername()), LogFields.errorType(throwable)); return Mono.just(KeycloakExceptionHandler.handleException(throwable)); }); } @@ -67,7 +68,7 @@ public Mono changePassword(ChangePasswordRequest request) { try { performChangePassword(request); } catch (Throwable t) { - log.error("Error changing password for user: {}", request.getUserId(), t); + log.error("Error changing password for user: {} errorType={}", LogFields.text(request.getUserId()), LogFields.errorType(t)); } return Mono.empty(); }); @@ -79,7 +80,7 @@ public Mono resetPassword(String username) { try { performResetPassword(username); } catch (Throwable t) { - log.error("Error resetting password for user: {}", username, t); + log.error("Error resetting password for user: {} errorType={}", LogFields.text(username), LogFields.errorType(t)); } return Mono.empty(); }); @@ -93,7 +94,7 @@ public Mono assignTemporaryPassword(String userId) { } private String performAssignTemporaryPassword(String userId) { - log.debug("Assigning temporary password for userId: {}", userId); + log.debug("Assigning temporary password for userId: {}", LogFields.text(userId)); if (userId == null || userId.isBlank()) { throw new WebApplicationException("userId cannot be null or empty"); } @@ -124,7 +125,7 @@ public Mono>> listSessions(String userId) { return Mono.fromCallable(() -> performListSessions(userId)) .map(ResponseEntity::ok) .onErrorResume(throwable -> { - log.error("Error listing sessions for user: {}", userId, throwable); + log.error("Error listing sessions for user: {} errorType={}", LogFields.text(userId), LogFields.errorType(throwable)); return Mono.just(KeycloakExceptionHandler.handleException(throwable)); }); } @@ -135,7 +136,7 @@ public Mono revokeSession(String sessionId) { try { performRevokeSession(sessionId); } catch (Throwable t) { - log.error("Error revoking session: {}", sessionId, t); + log.error("Error revoking session: {} errorType={}", LogFields.text(sessionId), LogFields.errorType(t)); } return Mono.empty(); }); @@ -146,7 +147,7 @@ public Mono>> getRoles(String userId) { return Mono.fromCallable(() -> performGetRoles(userId)) .map(ResponseEntity::ok) .onErrorResume(throwable -> { - log.error("Error getting roles for user: {}", userId, throwable); + log.error("Error getting roles for user: {} errorType={}", LogFields.text(userId), LogFields.errorType(throwable)); return Mono.just(KeycloakExceptionHandler.handleException(throwable)); }); } @@ -157,7 +158,7 @@ public Mono deleteUser(String userId) { try { performDeleteUser(userId); } catch (Throwable t) { - log.error("Error deleting user: {}", userId, t); + log.error("Error deleting user: {} errorType={}", LogFields.text(userId), LogFields.errorType(t)); } return Mono.empty(); }); @@ -168,7 +169,7 @@ public Mono> updateUser(UpdateUserRequest req return Mono.fromCallable(() -> performUpdateUser(request)) .map(ResponseEntity::ok) .onErrorResume(throwable -> { - log.error("Error updating user: {}", request.getUserId(), throwable); + log.error("Error updating user: {} errorType={}", LogFields.text(request.getUserId()), LogFields.errorType(throwable)); return Mono.just(KeycloakExceptionHandler.handleException(throwable)); }); } @@ -178,7 +179,7 @@ public Mono> createRoles(CreateRolesRequest return Mono.fromCallable(() -> performCreateRoles(request)) .map(ResponseEntity::ok) .onErrorResume(throwable -> { - log.error("Error creating roles: {}", request.getRoleNames(), throwable); + log.error("Error creating roles: {} errorType={}", LogFields.values(request.getRoleNames()), LogFields.errorType(throwable)); return Mono.just(KeycloakExceptionHandler.handleException(throwable)); }); } @@ -188,7 +189,7 @@ public Mono> createScope(CreateScopeRequest return Mono.fromCallable(() -> performCreateScope(request)) .map(ResponseEntity::ok) .onErrorResume(throwable -> { - log.error("Error creating scope: {}", request.getName(), throwable); + log.error("Error creating scope: {} errorType={}", LogFields.text(request.getName()), LogFields.errorType(throwable)); return Mono.just(KeycloakExceptionHandler.handleException(throwable)); }); } @@ -199,7 +200,7 @@ public Mono assignRolesToUser(AssignRolesRequest request) { try { performAssignRolesToUser(request); } catch (Throwable t) { - log.error("Error assigning roles to user: {}", request.getUserId(), t); + log.error("Error assigning roles to user: {} errorType={}", LogFields.text(request.getUserId()), LogFields.errorType(t)); } return Mono.empty(); }); @@ -211,7 +212,7 @@ public Mono removeRolesFromUser(AssignRolesRequest request) { try { performRemoveRolesFromUser(request); } catch (Throwable t) { - log.error("Error removing roles from user: {}", request.getUserId(), t); + log.error("Error removing roles from user: {} errorType={}", LogFields.text(request.getUserId()), LogFields.errorType(t)); } return Mono.empty(); }); @@ -222,7 +223,7 @@ public Mono>> listRoles() { return Mono.fromCallable(this::performListRoles) .map(ResponseEntity::ok) .onErrorResume(throwable -> { - log.error("Error listing realm roles", throwable); + log.error("Error listing realm roles errorType={}", LogFields.errorType(throwable)); return Mono.just(KeycloakExceptionHandler.handleException(throwable)); }); } @@ -230,20 +231,20 @@ public Mono>> listRoles() { @Override public Mono deleteRole(String roleName) { return Mono.fromRunnable(() -> performDeleteRole(roleName)) - .doOnError(t -> log.error("Error deleting realm role: {}", roleName, t)); + .doOnError(t -> log.error("Error deleting realm role: {} errorType={}", LogFields.text(roleName), LogFields.errorType(t))); } @Override public Mono updateRole(UpdateRoleRequest request) { return Mono.fromRunnable(() -> performUpdateRole(request)) - .doOnError(t -> log.error("Error updating realm role: {}", - request == null ? null : request.getName(), t)); + .doOnError(t -> log.error("Error updating realm role: {} errorType={}", + LogFields.text(request == null ? null : request.getName()), LogFields.errorType(t))); } // Private methods for business logic private CreateUserResponse performCreateUser(CreateUserRequest request) { - log.debug("Creating user: {}", request.getUsername()); + log.debug("Creating user: {}", LogFields.text(request.getUsername())); String userId; try (Keycloak keycloak = keycloakFactory.createClientCredentialsClient()) { @@ -302,7 +303,7 @@ private CreateUserResponse performCreateUser(CreateUserRequest request) { } private void performChangePassword(ChangePasswordRequest request) { - log.debug("Changing password for user: {}", request.getUserId()); + log.debug("Changing password for user: {}", LogFields.text(request.getUserId())); String username = getUsernameById(request.getUserId()); @@ -334,7 +335,7 @@ private void performChangePassword(ChangePasswordRequest request) { } private void performResetPassword(String username) { - log.debug("Resetting password for username: {}", username); + log.debug("Resetting password for username: {}", LogFields.text(username)); if (username == null || username.isBlank()) { throw new WebApplicationException("username cannot be null or empty"); @@ -385,7 +386,7 @@ private String getUsernameById(String userId) { } private List performListSessions(String userId) { - log.debug("Listing sessions for user: {}", userId); + log.debug("Listing sessions for user: {}", LogFields.text(userId)); try (Keycloak keycloak = keycloakFactory.createClientCredentialsClient()) { RealmResource realmResource = keycloak.realm(keycloakFactory.getRealm()); @@ -408,7 +409,7 @@ private List performListSessions(String userId) { } private void performRevokeSession(String sessionId) { - log.debug("Revoking session: {}", sessionId); + log.debug("Revoking session: {}", LogFields.text(sessionId)); if (sessionId == null || sessionId.isBlank()) { throw new WebApplicationException("SessionId cannot be null or empty"); @@ -421,7 +422,7 @@ private void performRevokeSession(String sessionId) { } private List performGetRoles(String userId) { - log.debug("Getting roles for user: {}", userId); + log.debug("Getting roles for user: {}", LogFields.text(userId)); try (Keycloak keycloak = keycloakFactory.createClientCredentialsClient()) { UserResource userResource = keycloak.realm(keycloakFactory.getRealm()).users().get(userId); @@ -452,7 +453,7 @@ private List performGetRoles(String userId) { } private void performDeleteUser(String userId) { - log.debug("Deleting user: {}", userId); + log.debug("Deleting user: {}", LogFields.text(userId)); if (userId == null || userId.isBlank()) { throw new WebApplicationException("userId cannot be null or empty"); @@ -465,7 +466,7 @@ private void performDeleteUser(String userId) { } private UpdateUserResponse performUpdateUser(UpdateUserRequest request) { - log.debug("Updating user: {}", request.getUserId()); + log.debug("Updating user: {}", LogFields.text(request.getUserId())); if (request.getUserId() == null || request.getUserId().isBlank()) { throw new WebApplicationException("userId cannot be null or empty"); @@ -509,7 +510,7 @@ private UpdateUserResponse performUpdateUser(UpdateUserRequest request) { } private CreateRolesResponse performCreateRoles(CreateRolesRequest request) { - log.debug("Creating roles: {}", request.getRoleNames()); + log.debug("Creating roles: {}", LogFields.values(request.getRoleNames())); if (request.getRoleNames() == null || request.getRoleNames().isEmpty()) { throw new WebApplicationException("roleNames cannot be null or empty"); @@ -568,7 +569,7 @@ private List performListRoles() { } private void performDeleteRole(String roleName) { - log.debug("Deleting realm role: {}", roleName); + log.debug("Deleting realm role: {}", LogFields.text(roleName)); if (roleName == null || roleName.isBlank()) { throw new WebApplicationException("roleName cannot be null or empty"); @@ -581,7 +582,7 @@ private void performDeleteRole(String roleName) { } private void performUpdateRole(UpdateRoleRequest request) { - log.debug("Updating realm role: {}", request == null ? null : request.getName()); + log.debug("Updating realm role: {}", LogFields.text(request == null ? null : request.getName())); if (request == null || request.getName() == null || request.getName().isBlank()) { throw new WebApplicationException("role name cannot be null or empty"); @@ -600,7 +601,7 @@ private void performUpdateRole(UpdateRoleRequest request) { } private CreateScopeResponse performCreateScope(CreateScopeRequest request) { - log.debug("Creating scope: {}", request.getName()); + log.debug("Creating scope: {}", LogFields.text(request.getName())); if (request.getName() == null || request.getName().isBlank()) { throw new WebApplicationException("Scope name cannot be null or empty"); @@ -642,7 +643,7 @@ private CreateScopeResponse performCreateScope(CreateScopeRequest request) { } private void performAssignRolesToUser(AssignRolesRequest request) { - log.debug("Assigning roles {} to user: {}", request.getRoleNames(), request.getUserId()); + log.debug("Assigning roles {} to user: {}", LogFields.values(request.getRoleNames()), LogFields.text(request.getUserId())); if (request.getUserId() == null || request.getUserId().isBlank()) { throw new WebApplicationException("userId cannot be null or empty"); @@ -671,7 +672,7 @@ private void performAssignRolesToUser(AssignRolesRequest request) { } private void performRemoveRolesFromUser(AssignRolesRequest request) { - log.debug("Removing roles {} from user: {}", request.getRoleNames(), request.getUserId()); + log.debug("Removing roles {} from user: {}", LogFields.values(request.getRoleNames()), LogFields.text(request.getUserId())); if (request.getUserId() == null || request.getUserId().isBlank()) { throw new WebApplicationException("userId cannot be null or empty"); From 78d185bab4b72405771cf9abf587d1ba9c738ae8 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 16:25:06 -0700 Subject: [PATCH 22/44] Keep data-processing logs free of untrusted payloads --- .../controller/AbstractDataJobController.java | 124 ++++--- .../data/observability/JobTracingService.java | 49 ++- .../AbstractResilientDataJobService.java | 60 +++- .../data/service/DataEnricher.java | 50 ++- .../logging/StarterDataLogSafetyTest.java | 316 ++++++++++++++++++ 5 files changed, 511 insertions(+), 88 deletions(-) create mode 100644 modules/starter-data/src/test/java/org/fireflyframework/data/logging/StarterDataLogSafetyTest.java diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/AbstractDataJobController.java b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/AbstractDataJobController.java index 8199e5cb6..f461b36df 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/AbstractDataJobController.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/AbstractDataJobController.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.controller; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.model.JobStage; import org.fireflyframework.data.model.JobStageRequest; import org.fireflyframework.data.model.JobStageResponse; @@ -50,11 +51,11 @@ protected AbstractDataJobController(DataJobService dataJobService) { @Override public Mono startJob(JobStartRequest request) { - log.info("Received START job request - parameters: {}, requestId: {}, initiator: {}", - request.getParameters() != null ? request.getParameters().keySet() : "none", - request.getRequestId(), - request.getInitiator()); - log.debug("Full START job request: {}", request); + log.info("Received START job request - parametersPresent: {}, requestId: {}, initiator: {}", + request.getParameters() != null, + LogFields.text(request.getRequestId()), + LogFields.text(request.getInitiator())); + log.debug("START job request type: {}", JobStartRequest.class.getSimpleName()); // Convert JobStartRequest to JobStageRequest for the service layer JobStageRequest stageRequest = JobStageRequest.builder() @@ -69,21 +70,26 @@ public Mono startJob(JobStartRequest request) { .doOnSuccess(response -> { if (response.isSuccess()) { log.info("START job completed successfully - executionId: {}, status: {}", - response.getExecutionId(), response.getStatus()); + LogFields.text(response.getExecutionId()), + response.getStatus()); } else { - log.warn("START job completed with failure - executionId: {}, status: {}, message: {}", - response.getExecutionId(), response.getStatus(), response.getMessage()); + log.warn("START job completed with failure - executionId: {}, status: {}, responseType: {}", + LogFields.text(response.getExecutionId()), + response.getStatus(), + JobStageResponse.class.getSimpleName()); } - log.debug("Full START job response: {}", response); + log.debug("START job response type: {}", JobStageResponse.class.getSimpleName()); }) .doOnError(error -> { - log.error("START job failed with error: {}", error.getMessage(), error); + log.error("START job failed with errorType: {}", LogFields.errorType(error)); }); } @Override public Mono checkJob(String executionId, String requestId) { - log.info("Received CHECK job request - executionId: {}, requestId: {}", executionId, requestId); + log.info("Received CHECK job request - executionId: {}, requestId: {}", + LogFields.text(executionId), + LogFields.text(requestId)); JobStageRequest request = JobStageRequest.builder() .stage(JobStage.CHECK) @@ -94,23 +100,31 @@ public Mono checkJob(String executionId, String requestId) { return dataJobService.checkJob(request) .doOnSuccess(response -> { if (response.isSuccess()) { - log.info("CHECK job completed successfully - executionId: {}, status: {}", - executionId, response.getStatus()); + log.info("CHECK job completed successfully - executionId: {}, status: {}", + LogFields.text(executionId), + response.getStatus()); } else { - log.warn("CHECK job completed with failure - executionId: {}, status: {}, message: {}", - executionId, response.getStatus(), response.getMessage()); + log.warn("CHECK job completed with failure - executionId: {}, status: {}, responseType: {}", + LogFields.text(executionId), + response.getStatus(), + JobStageResponse.class.getSimpleName()); } - log.debug("Full CHECK job response for executionId {}: {}", executionId, response); + log.debug("CHECK job response type for executionId {}: {}", + LogFields.text(executionId), + JobStageResponse.class.getSimpleName()); }) .doOnError(error -> { - log.error("CHECK job failed for executionId {} with error: {}", - executionId, error.getMessage(), error); + log.error("CHECK job failed for executionId {} with errorType: {}", + LogFields.text(executionId), + LogFields.errorType(error)); }); } @Override public Mono collectJobResults(String executionId, String requestId) { - log.info("Received COLLECT job results request - executionId: {}, requestId: {}", executionId, requestId); + log.info("Received COLLECT job results request - executionId: {}, requestId: {}", + LogFields.text(executionId), + LogFields.text(requestId)); JobStageRequest request = JobStageRequest.builder() .stage(JobStage.COLLECT) @@ -121,25 +135,33 @@ public Mono collectJobResults(String executionId, String reque return dataJobService.collectJobResults(request) .doOnSuccess(response -> { if (response.isSuccess()) { - log.info("COLLECT job results completed successfully - executionId: {}, status: {}, dataKeys: {}", - executionId, response.getStatus(), - response.getData() != null ? response.getData().keySet() : "none"); + log.info("COLLECT job results completed successfully - executionId: {}, status: {}, dataPresent: {}", + LogFields.text(executionId), + response.getStatus(), + response.getData() != null); } else { - log.warn("COLLECT job results completed with failure - executionId: {}, status: {}, message: {}", - executionId, response.getStatus(), response.getMessage()); + log.warn("COLLECT job results completed with failure - executionId: {}, status: {}, responseType: {}", + LogFields.text(executionId), + response.getStatus(), + JobStageResponse.class.getSimpleName()); } - log.debug("Full COLLECT job results response for executionId {}: {}", executionId, response); + log.debug("COLLECT job results response type for executionId {}: {}", + LogFields.text(executionId), + JobStageResponse.class.getSimpleName()); }) .doOnError(error -> { - log.error("COLLECT job results failed for executionId {} with error: {}", - executionId, error.getMessage(), error); + log.error("COLLECT job results failed for executionId {} with errorType: {}", + LogFields.text(executionId), + LogFields.errorType(error)); }); } @Override public Mono getJobResult(String executionId, String requestId, String targetDtoClass) { log.info("Received GET job result request - executionId: {}, requestId: {}, targetDtoClass: {}", - executionId, requestId, targetDtoClass); + LogFields.text(executionId), + LogFields.text(requestId), + LogFields.text(targetDtoClass)); JobStageRequest request = JobStageRequest.builder() .stage(JobStage.RESULT) @@ -151,25 +173,33 @@ public Mono getJobResult(String executionId, String requestId, return dataJobService.getJobResult(request) .doOnSuccess(response -> { if (response.isSuccess()) { - log.info("GET job result completed successfully - executionId: {}, status: {}, dataKeys: {}", - executionId, response.getStatus(), - response.getData() != null ? response.getData().keySet() : "none"); + log.info("GET job result completed successfully - executionId: {}, status: {}, dataPresent: {}", + LogFields.text(executionId), + response.getStatus(), + response.getData() != null); } else { - log.warn("GET job result completed with failure - executionId: {}, status: {}, message: {}", - executionId, response.getStatus(), response.getMessage()); + log.warn("GET job result completed with failure - executionId: {}, status: {}, responseType: {}", + LogFields.text(executionId), + response.getStatus(), + JobStageResponse.class.getSimpleName()); } - log.debug("Full GET job result response for executionId {}: {}", executionId, response); + log.debug("GET job result response type for executionId {}: {}", + LogFields.text(executionId), + JobStageResponse.class.getSimpleName()); }) .doOnError(error -> { - log.error("GET job result failed for executionId {} with error: {}", - executionId, error.getMessage(), error); + log.error("GET job result failed for executionId {} with errorType: {}", + LogFields.text(executionId), + LogFields.errorType(error)); }); } @Override public Mono stopJob(String executionId, String requestId, String reason) { - log.info("Received STOP job request - executionId: {}, requestId: {}, reason: {}", - executionId, requestId, reason); + log.info("Received STOP job request - executionId: {}, requestId: {}, reasonProvided: {}", + LogFields.text(executionId), + LogFields.text(requestId), + reason != null); JobStageRequest request = JobStageRequest.builder() .stage(JobStage.STOP) @@ -182,16 +212,22 @@ public Mono stopJob(String executionId, String requestId, Stri .doOnSuccess(response -> { if (response.isSuccess()) { log.info("STOP job completed successfully - executionId: {}, status: {}", - executionId, response.getStatus()); + LogFields.text(executionId), + response.getStatus()); } else { - log.warn("STOP job completed with failure - executionId: {}, status: {}, message: {}", - executionId, response.getStatus(), response.getMessage()); + log.warn("STOP job completed with failure - executionId: {}, status: {}, responseType: {}", + LogFields.text(executionId), + response.getStatus(), + JobStageResponse.class.getSimpleName()); } - log.debug("Full STOP job response for executionId {}: {}", executionId, response); + log.debug("STOP job response type for executionId {}: {}", + LogFields.text(executionId), + JobStageResponse.class.getSimpleName()); }) .doOnError(error -> { - log.error("STOP job failed for executionId {} with error: {}", - executionId, error.getMessage(), error); + log.error("STOP job failed for executionId {} with errorType: {}", + LogFields.text(executionId), + LogFields.errorType(error)); }); } diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/observability/JobTracingService.java b/modules/starter-data/src/main/java/org/fireflyframework/data/observability/JobTracingService.java index 5962b22b7..6003fb992 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/observability/JobTracingService.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/observability/JobTracingService.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.observability; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.config.JobOrchestrationProperties; import org.fireflyframework.data.model.JobStage; import io.micrometer.observation.Observation; @@ -61,20 +62,29 @@ public Mono traceJobOperation(JobStage stage, String executionId, Mono return operation .doOnSubscribe(s -> { observation.start(); - log.debug("Started tracing for stage {} with execution ID {}", stage, executionId); + log.debug("Started tracing for stage {} with execution ID {}", + stage, + LogFields.text(executionId)); }) .doOnSuccess(result -> { observation.event(Observation.Event.of("job.stage.success", "Job stage completed successfully")); - log.debug("Job stage {} completed successfully for execution {}", stage, executionId); + log.debug("Job stage {} completed successfully for execution {}", + stage, + LogFields.text(executionId)); }) .doOnError(error -> { observation.error(error); observation.event(Observation.Event.of("job.stage.error", "Job stage failed: " + error.getMessage())); - log.error("Job stage {} failed for execution {}: {}", stage, executionId, error.getMessage()); + log.error("Job stage {} failed for execution {}: {}", + stage, + LogFields.text(executionId), + LogFields.errorType(error)); }) .doFinally(signalType -> { observation.stop(); - log.debug("Stopped tracing for stage {} with execution ID {}", stage, executionId); + log.debug("Stopped tracing for stage {} with execution ID {}", + stage, + LogFields.text(executionId)); }); }); } @@ -97,22 +107,25 @@ public T traceJobOperationSync(JobStage stage, String executionId, Supplier< try { observation.start(); - log.debug("Started tracing for stage {} with execution ID {}", stage, executionId); + log.debug("Started tracing for stage {} with execution ID {}", stage, LogFields.text(executionId)); T result = operation.get(); observation.event(Observation.Event.of("job.stage.success", "Job stage completed successfully")); - log.debug("Job stage {} completed successfully for execution {}", stage, executionId); + log.debug("Job stage {} completed successfully for execution {}", stage, LogFields.text(executionId)); return result; } catch (Exception e) { observation.error(e); observation.event(Observation.Event.of("job.stage.error", "Job stage failed: " + e.getMessage())); - log.error("Job stage {} failed for execution {}: {}", stage, executionId, e.getMessage()); + log.error("Job stage {} failed for execution {}: {}", + stage, + LogFields.text(executionId), + LogFields.errorType(e)); throw e; } finally { observation.stop(); - log.debug("Stopped tracing for stage {} with execution ID {}", stage, executionId); + log.debug("Stopped tracing for stage {} with execution ID {}", stage, LogFields.text(executionId)); } } @@ -179,7 +192,8 @@ public void recordEvent(String eventName, String eventDescription) { if (currentObservation != null) { currentObservation.event(Observation.Event.of(eventName, eventDescription)); } else { - log.debug("No current observation found when recording event '{}' - context may have switched threads", eventName); + log.debug("No current observation found when recording event '{}' - context may have switched threads", + LogFields.text(eventName)); } } @@ -208,20 +222,29 @@ public Mono traceOperation(String operationName, String operationId, Mono return operation .doOnSubscribe(s -> { observation.start(); - log.debug("Started tracing for operation {} with ID {}", operationName, operationId); + log.debug("Started tracing for operation {} with ID {}", + LogFields.text(operationName), + LogFields.text(operationId)); }) .doOnSuccess(result -> { observation.event(Observation.Event.of("operation.success", "Operation completed successfully")); - log.debug("Operation {} completed successfully for ID {}", operationName, operationId); + log.debug("Operation {} completed successfully for ID {}", + LogFields.text(operationName), + LogFields.text(operationId)); }) .doOnError(error -> { observation.error(error); observation.event(Observation.Event.of("operation.error", "Operation failed: " + error.getMessage())); - log.error("Operation {} failed for ID {}: {}", operationName, operationId, error.getMessage()); + log.error("Operation {} failed for ID {}: {}", + LogFields.text(operationName), + LogFields.text(operationId), + LogFields.errorType(error)); }) .doFinally(signalType -> { observation.stop(); - log.debug("Stopped tracing for operation {} with ID {}", operationName, operationId); + log.debug("Stopped tracing for operation {} with ID {}", + LogFields.text(operationName), + LogFields.text(operationId)); }); }); } diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/service/AbstractResilientDataJobService.java b/modules/starter-data/src/main/java/org/fireflyframework/data/service/AbstractResilientDataJobService.java index 1b558bdf2..104f53074 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/service/AbstractResilientDataJobService.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/service/AbstractResilientDataJobService.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.service; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.event.JobEventPublisher; import org.fireflyframework.data.model.JobStage; import org.fireflyframework.data.model.JobStageRequest; @@ -151,10 +152,14 @@ private Mono executeWithObservabilityAndResiliency( String orchestratorType = getOrchestratorType(); // Log stage start with request details - log.info("Starting {} stage - executionId: {}, parameters: {}", - stage, executionId, request.getParameters() != null ? request.getParameters().keySet() : "none"); - log.debug("Full request details for {} stage - executionId: {}, request: {}", - stage, executionId, request); + log.info("Starting {} stage - executionId: {}, parametersPresent: {}", + stage, + LogFields.text(executionId), + request.getParameters() != null); + log.debug("Request type for {} stage - executionId: {}, requestType: {}", + stage, + LogFields.text(executionId), + JobStageRequest.class.getSimpleName()); // Record audit entry for operation started if (auditService != null) { @@ -179,7 +184,10 @@ private Mono executeWithObservabilityAndResiliency( .timeout(timeout) .onErrorResume(java.util.concurrent.TimeoutException.class, e -> { Duration duration = Duration.between(startTime, Instant.now()); - log.warn("Job stage {} timed out after {} - executionId: {}", stage, timeout, executionId); + log.warn("Job stage {} timed out after {} - executionId: {}", + stage, + timeout, + LogFields.text(executionId)); metricsService.recordJobError(stage, "TimeoutException"); metricsService.incrementJobStageCounter(stage, "timeout"); return Mono.just(JobStageResponse.failure(stage, executionId, @@ -190,7 +198,7 @@ private Mono executeWithObservabilityAndResiliency( // Add metrics, logging, and persistence return resilientOperation .doOnSubscribe(subscription -> { - log.debug("Subscribed to {} stage operation for execution {}", stage, executionId); + log.debug("Subscribed to {} stage operation for execution {}", stage, LogFields.text(executionId)); }) .doOnSuccess(response -> { Duration duration = Duration.between(startTime, Instant.now()); @@ -225,14 +233,22 @@ private Mono executeWithObservabilityAndResiliency( if (response.isSuccess()) { log.info("Successfully completed {} stage - executionId: {}, duration: {}ms, status: {}", - stage, executionId, duration.toMillis(), response.getStatus()); - log.debug("Response details for {} stage - executionId: {}, data keys: {}, message: {}", - stage, executionId, - response.getData() != null ? response.getData().keySet() : "none", - response.getMessage()); + stage, + LogFields.text(executionId), + duration.toMillis(), + response.getStatus()); + log.debug("Response details for {} stage - executionId: {}, dataPresent: {}, responseType: {}", + stage, + LogFields.text(executionId), + response.getData() != null, + JobStageResponse.class.getSimpleName()); } else { - log.warn("Completed {} stage with failure - executionId: {}, duration: {}ms, status: {}, message: {}", - stage, executionId, duration.toMillis(), response.getStatus(), response.getMessage()); + log.warn("Completed {} stage with failure - executionId: {}, duration: {}ms, status: {}, responseType: {}", + stage, + LogFields.text(executionId), + duration.toMillis(), + response.getStatus(), + JobStageResponse.class.getSimpleName()); } }) .doOnError(error -> { @@ -264,13 +280,21 @@ private Mono executeWithObservabilityAndResiliency( eventPublisher.publishJobFailed(executionId, request.getJobType(), error.getMessage(), error); } - log.error("Failed {} stage - executionId: {}, duration: {}ms, errorType: {}, errorMessage: {}", - stage, executionId, duration.toMillis(), error.getClass().getSimpleName(), error.getMessage()); - log.debug("Full error details for {} stage - executionId: {}", stage, executionId, error); + log.error("Failed {} stage - executionId: {}, duration: {}ms, errorType: {}", + stage, + LogFields.text(executionId), + duration.toMillis(), + LogFields.errorType(error)); + log.debug("Error type details for {} stage - executionId: {}, errorType: {}", + stage, + LogFields.text(executionId), + LogFields.errorType(error)); }) .onErrorResume(error -> { - log.warn("Returning failure response for {} stage - executionId: {}, error: {}", - stage, executionId, error.getMessage()); + log.warn("Returning failure response for {} stage - executionId: {}, errorType: {}", + stage, + LogFields.text(executionId), + LogFields.errorType(error)); // Return a failure response instead of propagating the error return Mono.just(JobStageResponse.failure( stage, diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/service/DataEnricher.java b/modules/starter-data/src/main/java/org/fireflyframework/data/service/DataEnricher.java index 3ef4a0284..f07b88860 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/service/DataEnricher.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/service/DataEnricher.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.service; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.cache.EnrichmentCacheService; import org.fireflyframework.data.controller.EndpointAware; import org.fireflyframework.data.enrichment.EnricherMetadataReader; @@ -196,11 +197,15 @@ public final Mono enrich(EnrichmentRequest request) { .flatMap(cachedOpt -> { if (cachedOpt.isPresent()) { log.debug("Cache HIT for enrichment: type={}, provider={}, tenant={}", - request.getType(), providerName, request.getTenantId()); + LogFields.text(request.getType()), + LogFields.text(providerName), + request.getTenantId()); return Mono.just(cachedOpt.get()); } else { log.debug("Cache MISS for enrichment: type={}, provider={}, tenant={}", - request.getType(), providerName, request.getTenantId()); + LogFields.text(request.getType()), + LogFields.text(providerName), + request.getTenantId()); return enrichAndCache(request, providerName); } }); @@ -240,7 +245,9 @@ private Mono executeWithObservabilityAndResiliency(Enrichmen Instant startTime = Instant.now(); log.debug("Starting enrichment: type={}, provider={}, requestId={}", - enrichmentType, providerName, requestId); + LogFields.text(enrichmentType), + LogFields.text(providerName), + LogFields.text(requestId)); // Wrap with tracing Mono tracedOperation = tracingService.traceOperation( @@ -256,7 +263,9 @@ private Mono executeWithObservabilityAndResiliency(Enrichmen return resilientOperation .doOnSubscribe(subscription -> { log.debug("Subscribed to enrichment operation: type={}, provider={}, requestId={}", - enrichmentType, providerName, requestId); + LogFields.text(enrichmentType), + LogFields.text(providerName), + LogFields.text(requestId)); }) .doOnNext(response -> { long durationMillis = Duration.between(startTime, Instant.now()).toMillis(); @@ -279,7 +288,11 @@ private Mono executeWithObservabilityAndResiliency(Enrichmen } log.info("Enrichment completed: type={}, provider={}, success={}, duration={}ms, requestId={}", - enrichmentType, providerName, response.isSuccess(), durationMillis, requestId); + LogFields.text(enrichmentType), + LogFields.text(providerName), + response.isSuccess(), + durationMillis, + LogFields.text(requestId)); }) .doOnError(error -> { long durationMillis = Duration.between(startTime, Instant.now()).toMillis(); @@ -305,12 +318,18 @@ private Mono executeWithObservabilityAndResiliency(Enrichmen ); } - log.error("Enrichment failed: type={}, provider={}, error={}, duration={}ms, requestId={}", - enrichmentType, providerName, error.getMessage(), durationMillis, requestId, error); + log.error("Enrichment failed: type={}, provider={}, errorType={}, duration={}ms, requestId={}", + LogFields.text(enrichmentType), + LogFields.text(providerName), + LogFields.errorType(error), + durationMillis, + LogFields.text(requestId)); }) .onErrorResume(error -> { - log.warn("Returning failure response for enrichment - type: {}, provider: {}, error: {}", - enrichmentType, providerName, error.getMessage()); + log.warn("Returning failure response for enrichment - type: {}, provider: {}, errorType: {}", + LogFields.text(enrichmentType), + LogFields.text(providerName), + LogFields.errorType(error)); // Return a failure response instead of propagating the error return Mono.just(EnrichmentResponse.failure( @@ -326,7 +345,8 @@ private Mono executeWithObservabilityAndResiliency(Enrichmen */ protected final Mono doEnrich(EnrichmentRequest request) { log.debug("Starting typed enrichment for type: {}, strategy: {}", - request.getType(), request.getStrategy()); + LogFields.text(request.getType()), + request.getStrategy()); return fetchProviderData(request) .map(providerData -> { @@ -371,7 +391,8 @@ protected final Mono doEnrich(EnrichmentRequest request) { }) .onErrorResume(error -> { log.warn("Enrichment failed for type {}: {}", - request.getType(), error.getMessage()); + LogFields.text(request.getType()), + LogFields.errorType(error)); return Mono.just( EnrichmentResponseBuilder @@ -483,7 +504,9 @@ public Flux enrichBatch(List requests) { return Flux.empty(); } - log.info("Starting batch enrichment: provider={}, batchSize={}", getProviderName(), requests.size()); + log.info("Starting batch enrichment: provider={}, batchSize={}", + LogFields.text(getProviderName()), + requests.size()); // Process requests in parallel with controlled concurrency int parallelism = getBatchParallelism(); @@ -491,7 +514,8 @@ public Flux enrichBatch(List requests) { return Flux.fromIterable(requests) .flatMap(this::enrich, parallelism) .doOnComplete(() -> log.info("Batch enrichment completed: provider={}, batchSize={}", - getProviderName(), requests.size())); + LogFields.text(getProviderName()), + requests.size())); } /** diff --git a/modules/starter-data/src/test/java/org/fireflyframework/data/logging/StarterDataLogSafetyTest.java b/modules/starter-data/src/test/java/org/fireflyframework/data/logging/StarterDataLogSafetyTest.java new file mode 100644 index 000000000..786f8ba4d --- /dev/null +++ b/modules/starter-data/src/test/java/org/fireflyframework/data/logging/StarterDataLogSafetyTest.java @@ -0,0 +1,316 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.data.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.ObservationRegistry; +import org.fireflyframework.data.config.JobOrchestrationProperties; +import org.fireflyframework.data.controller.AbstractDataJobController; +import org.fireflyframework.data.model.*; +import org.fireflyframework.data.observability.JobMetricsService; +import org.fireflyframework.data.observability.JobTracingService; +import org.fireflyframework.data.resiliency.ResiliencyDecoratorService; +import org.fireflyframework.data.service.AbstractResilientDataJobService; +import org.fireflyframework.data.service.DataEnricher; +import org.fireflyframework.data.service.DataJobService; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class StarterDataLogSafetyTest { + private static final String ID = "correlation\r\nline\u2028end\u2029"; + private static final String PRIVATE = "private-payload-marker"; + + @Test + void controllerSuccessKeepsAllStageInputsAndResultsWithoutPayloadDiagnostics() { + try (Capture logs = new Capture(AbstractDataJobController.class)) { + ForwardingService service = new ForwardingService(); + AbstractDataJobController controller = new AbstractDataJobController(service) {}; + for (JobStage stage : List.of(JobStage.START, JobStage.CHECK, JobStage.COLLECT, JobStage.RESULT, JobStage.STOP)) { + service.response = JobStageResponse.success(stage, ID, PRIVATE); + service.response.setData(Map.of(PRIVATE, PRIVATE)); + assertSame(service.response, call(controller, stage)); + assertEquals(stage, service.request.getStage()); + assertEquals(ID, service.request.getRequestId()); + if (stage == JobStage.START) { + assertEquals(Map.of(PRIVATE, PRIVATE), service.request.getParameters()); + assertEquals(ID, service.request.getInitiator()); + } else assertEquals(ID, service.request.getExecutionId()); + if (stage == JobStage.STOP) assertEquals(PRIVATE, service.reason); + if (stage == JobStage.RESULT) assertEquals(ID, service.request.getTargetDtoClass()); + } + logs.safe(); + assertFalse(logs.text().contains(PRIVATE)); + assertTrue(logs.text().contains("parametersPresent: true")); + assertTrue(logs.text().contains("dataPresent: true")); + } + } + + @Test + void controllerFailureResponsesStayUnchangedAndRetainWarnLevel() { + try (Capture logs = new Capture(AbstractDataJobController.class)) { + ForwardingService service = new ForwardingService(); + AbstractDataJobController controller = new AbstractDataJobController(service) {}; + for (JobStage stage : List.of(JobStage.START, JobStage.CHECK, JobStage.COLLECT, JobStage.RESULT, JobStage.STOP)) { + service.response = JobStageResponse.failure(stage, ID, PRIVATE); + service.response.setMessage(PRIVATE); + assertSame(service.response, call(controller, stage)); + assertEquals(PRIVATE, service.response.getError()); + } + logs.safe(); + assertFalse(logs.text().contains(PRIVATE)); + assertEquals(5, logs.list.stream().filter(event -> event.getLevel() == Level.WARN).count()); + } + } + + @Test + void controllerPropagatesOriginalErrorsButLogsOnlyTheirType() { + try (Capture logs = new Capture(AbstractDataJobController.class)) { + ForwardingService service = new ForwardingService(); + service.error = new IllegalStateException(PRIVATE + "\n"); + AbstractDataJobController controller = new AbstractDataJobController(service) {}; + for (JobStage stage : List.of(JobStage.START, JobStage.CHECK, JobStage.COLLECT, JobStage.RESULT, JobStage.STOP)) { + assertSame(service.error, assertThrows(IllegalStateException.class, () -> call(controller, stage))); + } + logs.safe(); + assertFalse(logs.text().contains(PRIVATE)); + assertTrue(logs.text().contains(IllegalStateException.class.getName())); + assertEquals(5, logs.list.stream().filter(event -> event.getLevel() == Level.ERROR).count()); + } + } + + @Test + void reactiveTracingRetainsResultAndCorrelationWithoutLineBreaks() { + try (Capture logs = new Capture(JobTracingService.class)) { + JobTracingService tracing = tracing(); + Object result = new Object(); + MDC.put("correlationId", "unchanged-correlation"); + try { + assertSame(result, tracing.traceJobOperation(JobStage.START, ID, Mono.just(result)).block()); + assertSame(result, tracing.traceOperation(ID, ID, Mono.just(result)).block()); + tracing.recordEvent(ID, PRIVATE); + logs.safe(); + assertTrue(logs.text().contains("correlation\\r\\nline\\u2028end\\u2029")); + assertEquals("unchanged-correlation", MDC.get("correlationId")); + } finally { MDC.remove("correlationId"); } + } + } + + @Test + void reactiveTracingPropagatesOriginalErrorsWithoutMessageOrStackLogs() { + try (Capture logs = new Capture(JobTracingService.class)) { + IllegalStateException error = new IllegalStateException(PRIVATE); + JobTracingService tracing = tracing(); + assertSame(error, assertThrows(IllegalStateException.class, + () -> tracing.traceJobOperation(JobStage.START, ID, Mono.error(error)).block())); + assertSame(error, assertThrows(IllegalStateException.class, + () -> tracing.traceOperation(ID, ID, Mono.error(error)).block())); + logs.safe(); + assertFalse(logs.text().contains(PRIVATE)); + assertTrue(logs.text().contains(IllegalStateException.class.getName())); + } + } + + @Test + void synchronousTracingPreservesNormalValuesNullIdsAndOriginalException() { + try (Capture logs = new Capture(JobTracingService.class)) { + JobTracingService tracing = tracing(); + assertEquals("result", tracing.traceJobOperationSync(JobStage.START, "ordinary-id", () -> "result")); + IllegalArgumentException error = new IllegalArgumentException(PRIVATE); + assertSame(error, assertThrows(IllegalArgumentException.class, + () -> tracing.traceJobOperationSync(JobStage.CHECK, null, () -> { throw error; }))); + logs.safe(); + assertTrue(logs.text().contains("execution ID ordinary-id")); + assertTrue(logs.text().contains("execution null")); + assertFalse(logs.text().contains(PRIVATE)); + } + } + + @Test + void disabledTracingDoesNotSubscribeOrInvokeAnOperationMoreThanOnce() { + JobOrchestrationProperties properties = new JobOrchestrationProperties(); + properties.getObservability().setTracingEnabled(false); + JobTracingService tracing = new JobTracingService(ObservationRegistry.create(), properties); + AtomicInteger calls = new AtomicInteger(); + Mono operation = Mono.fromSupplier(calls::incrementAndGet); + assertSame(operation, tracing.traceOperation(ID, ID, operation)); + assertEquals(0, calls.get()); + assertEquals(1, operation.block()); + assertEquals(2, tracing.traceJobOperationSync(JobStage.START, ID, calls::incrementAndGet)); + } + + @Test + void resilientJobPreservesResponseAndFailureConversionWithoutPayloadLogs() { + try (Capture logs = new Capture(AbstractResilientDataJobService.class)) { + JobStageRequest request = JobStageRequest.builder().executionId(ID) + .parameters(Map.of(PRIVATE, PRIVATE)).build(); + JobStageResponse response = JobStageResponse.success(JobStage.START, ID, PRIVATE); + response.setData(Map.of(PRIVATE, PRIVATE)); + ResilientJob job = new ResilientJob(Mono.just(response)); + assertSame(response, job.startJob(request).block()); + assertSame(request, job.seen); + IllegalStateException error = new IllegalStateException(PRIVATE); + JobStageResponse failure = new ResilientJob(Mono.error(error)).startJob(request).block(); + assertFalse(failure.isSuccess()); + assertEquals("Error executing START stage: " + PRIVATE, failure.getError()); + logs.safe(); + assertFalse(logs.text().contains(PRIVATE)); + assertTrue(logs.text().contains(IllegalStateException.class.getName())); + } + } + + @Test + void resilientUnsuccessfulResponseIsReturnedWithoutLoggingItsMessage() { + try (Capture logs = new Capture(AbstractResilientDataJobService.class)) { + JobStageResponse response = JobStageResponse.failure(JobStage.CHECK, ID, PRIVATE); + response.setMessage(PRIVATE); + assertSame(response, new ResilientJob(Mono.just(response)).checkJob( + JobStageRequest.builder().executionId(ID).build()).block()); + logs.safe(); + assertFalse(logs.text().contains(PRIVATE)); + assertTrue(logs.list.stream().anyMatch(event -> event.getLevel() == Level.WARN)); + } + } + + @Test + void enricherRetainsRawOutputAndExistingFailureResponseWithoutPayloadLogs() { + try (Capture logs = new Capture(DataEnricher.class)) { + EnrichmentRequest request = EnrichmentRequest.builder().type(ID).requestId(ID) + .strategy(EnrichmentStrategy.RAW).build(); + Map provider = Map.of("result", PRIVATE); + TestEnricher success = new TestEnricher(Mono.just(provider)); + EnrichmentResponse response = success.enrich(request).block(); + assertTrue(response.isSuccess()); + assertSame(provider, response.getEnrichedData()); + assertSame(request, success.seen); + assertEquals(1, success.enrichBatch(List.of(request)).collectList().block().size()); + IllegalStateException error = new IllegalStateException(PRIVATE); + EnrichmentResponse failure = new TestEnricher(Mono.error(error)).enrich(request).block(); + assertFalse(failure.isSuccess()); + assertEquals("Enrichment failed: " + PRIVATE, failure.getError()); + logs.safe(); + assertFalse(logs.text().contains(PRIVATE)); + assertTrue(logs.text().contains(IllegalStateException.class.getName())); + } + } + + @Test + void diagnosticsDoNotEvaluateArbitraryParameterOrResultMaps() { + Map opaque = new java.util.AbstractMap<>() { + public java.util.Set> entrySet() { throw new AssertionError("Payload traversal forbidden"); } + public int size() { throw new IllegalStateException("Diagnostic count unavailable"); } + public java.util.Set keySet() { throw new IllegalStateException("Diagnostic keys unavailable"); } + }; + ForwardingService service = new ForwardingService(); + service.response = JobStageResponse.success(JobStage.COLLECT, "ordinary-id", "ordinary"); + service.response.setData(opaque); + AbstractDataJobController controller = new AbstractDataJobController(service) {}; + try (Capture logs = new Capture(AbstractDataJobController.class)) { + assertSame(service.response, controller.startJob(JobStartRequest.builder().parameters(opaque).build()).block()); + assertSame(opaque, service.request.getParameters()); + assertSame(service.response, controller.collectJobResults("ordinary-id", null).block()); + logs.safe(); + } + try (Capture logs = new Capture(AbstractResilientDataJobService.class)) { + assertSame(service.response, new ResilientJob(Mono.just(service.response)).startJob( + JobStageRequest.builder().parameters(opaque).build()).block()); + logs.safe(); + } + } + + private static JobTracingService tracing() { + return new JobTracingService(ObservationRegistry.create(), new JobOrchestrationProperties()); + } + + private static JobStageResponse call(AbstractDataJobController controller, JobStage stage) { + return switch (stage) { + case START -> controller.startJob(JobStartRequest.builder().requestId(ID).initiator(ID) + .parameters(Map.of(PRIVATE, PRIVATE)).build()).block(); + case CHECK -> controller.checkJob(ID, ID).block(); + case COLLECT -> controller.collectJobResults(ID, ID).block(); + case RESULT -> controller.getJobResult(ID, ID, ID).block(); + case STOP -> controller.stopJob(ID, ID, PRIVATE).block(); + default -> throw new IllegalArgumentException("Unexpected stage"); + }; + } + + private static class ForwardingService implements DataJobService { + JobStageRequest request; JobStageResponse response; RuntimeException error; String reason; + Mono result(JobStageRequest value) { + request = value; + return error == null ? Mono.just(response) : Mono.error(error); + } + public Mono startJob(JobStageRequest value) { return result(value); } + public Mono checkJob(JobStageRequest value) { return result(value); } + public Mono collectJobResults(JobStageRequest value) { return result(value); } + public Mono getJobResult(JobStageRequest value) { return result(value); } + public Mono stopJob(JobStageRequest value, String why) { reason = why; return result(value); } + } + + private static class ResilientJob extends AbstractResilientDataJobService { + private final Mono outcome; JobStageRequest seen; + ResilientJob(Mono outcome) { + super(tracing(), new JobMetricsService(new SimpleMeterRegistry(), new JobOrchestrationProperties()), + new ResiliencyDecoratorService(new JobOrchestrationProperties())); + this.outcome = outcome; + } + protected Mono doStartJob(JobStageRequest request) { seen = request; return outcome; } + protected Mono doCheckJob(JobStageRequest request) { seen = request; return outcome; } + protected Mono doCollectJobResults(JobStageRequest request) { seen = request; return outcome; } + protected Mono doGetJobResult(JobStageRequest request) { seen = request; return outcome; } + protected Mono doStopJob(JobStageRequest request, String reason) { seen = request; return outcome; } + } + + private static class TestEnricher extends DataEnricher,Object> { + private final Mono> outcome; EnrichmentRequest seen; + TestEnricher(Mono> outcome) { + super(tracing(), null, new ResiliencyDecoratorService(new JobOrchestrationProperties()), Object.class); + this.outcome = outcome; + } + protected Mono> fetchProviderData(EnrichmentRequest request) { seen = request; return outcome; } + protected Object mapToTarget(Map value) { return value; } + public String getProviderName() { return ID; } + } + + private static class Capture extends ListAppender implements AutoCloseable { + private final Logger logger; private final Level previous; + Capture(Class type) { + logger = (Logger) LoggerFactory.getLogger(type); previous = logger.getLevel(); + logger.setLevel(Level.DEBUG); start(); logger.addAppender(this); + } + String text() { return String.join(" | ", list.stream().map(ILoggingEvent::getFormattedMessage).toList()); } + void safe() { + assertFalse(list.isEmpty()); + for (ILoggingEvent event : list) { + assertFalse(event.getFormattedMessage().matches("(?s).*[\r\n\u000b\f\u0085\u2028\u2029].*"), event.getFormattedMessage()); + assertNull(event.getThrowableProxy()); + } + } + public void close() { logger.detachAppender(this); logger.setLevel(previous); stop(); } + } +} From 13219db5d5fac7c7ea9cb809067d8ebd38232c75 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 18:32:52 -0700 Subject: [PATCH 23/44] Keep R2DBC filter diagnostics free of request payloads --- .../core/filters/FilterUtils.java | 9 +- .../core/filters/FilterUtilsLoggingTest.java | 133 ++++++++++++++++++ 2 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterUtilsLoggingTest.java diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterUtils.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterUtils.java index 1bd3ce34a..093e8bfc8 100644 --- a/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterUtils.java +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/filters/FilterUtils.java @@ -3,6 +3,7 @@ import org.fireflyframework.core.queries.PaginationRequest; import org.fireflyframework.core.queries.PaginationResponse; import org.fireflyframework.core.queries.PaginationUtils; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.utils.annotations.FilterableId; import lombok.AllArgsConstructor; import lombok.Builder; @@ -100,7 +101,7 @@ private GenericFilter(Class entityClass, Function mapper, FilterOptions public Mono> filter(FilterRequest filterRequest) { Criteria criteria = buildCriteria(filterRequest); // Build Criteria from the filter request - log.info("pagination request is {}", filterRequest.getPagination()); + log.info("Pagination request present={}", filterRequest.getPagination() != null); // Use default pagination if no pagination provided in request PaginationRequest paginationRequest = filterRequest.getPagination() != null ? filterRequest.getPagination() : @@ -233,9 +234,9 @@ else if (!(value instanceof String)) { } } } catch (IllegalAccessException e) { - log.error("Error accessing field: {}", field.getName(), e); // Log error for inaccessible fields + log.error("Error accessing field {}: type={}", field.getName(), LogFields.errorType(e)); // Log error for inaccessible fields } catch (Exception e) { - log.error("Unexpected error processing filter field {}: {}", field.getName(), e.getMessage(), e); + log.error("Unexpected error processing filter field {}: type={}", field.getName(), LogFields.errorType(e)); } } @@ -274,7 +275,7 @@ private List processRangeFilters(RangeFilter rangeFilters) { rangeFilters.getRanges().forEach((fieldName, range) -> { // Skip ID fields for range filters if (isAnyIdField(fieldName, entityClass)) { - log.debug("Skipping range filter for ID field: {}", fieldName); + log.debug("Skipping range filter for ID field: {}", LogFields.text(fieldName)); return; } diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterUtilsLoggingTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterUtilsLoggingTest.java new file mode 100644 index 000000000..c4ccb47c7 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/filters/FilterUtilsLoggingTest.java @@ -0,0 +1,133 @@ +package org.fireflyframework.core.filters; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.AbstractCollection; +import java.util.Collection; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.fireflyframework.core.queries.PaginationRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.core.ReactiveSelectOperation.ReactiveSelect; +import org.springframework.data.r2dbc.core.ReactiveSelectOperation.TerminatingSelect; +import org.springframework.data.relational.core.query.Query; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +class FilterUtilsLoggingTest { + private final Logger logger = (Logger) LoggerFactory.getLogger(FilterUtils.class); + private final ListAppender events = new ListAppender<>(); + private Level previousLevel; + + @BeforeEach + void captureLogs() { + previousLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + events.start(); + logger.addAppender(events); + } + + @AfterEach + void releaseLogs() { + logger.detachAppender(events); + events.stop(); + logger.setLevel(previousLevel); + FilterUtils.initializeTemplate(null); + } + + @Test + void paginationLogsPresenceWithoutRenderingRequestDetails() { + var rendered = new AtomicInteger(); + var pagination = new PaginationRequest(0, 10, "private\r\nfield\u2028value", "ASC") { + @Override public String toString() { + rendered.incrementAndGet(); + return "PRIVATE_PAGINATION\r\nforged event"; + } + }; + var result = paginatedFilter().filter(FilterRequest.builder().pagination(pagination).build()).block(); + assertNotNull(result); + assertEquals(0, result.getTotalElements()); + assertEquals("Pagination request present=true", events.list.getFirst().getFormattedMessage()); + assertEquals(Level.INFO, events.list.getFirst().getLevel()); + assertEquals(0, rendered.get()); + assertNull(events.list.getFirst().getThrowableProxy()); + } + + @Test + void absentPaginationRetainsDefaultPagingAndLogsOnlyPresence() { + var result = paginatedFilter().filter(FilterRequest.builder().build()).block(); + assertNotNull(result); + assertEquals(0, result.getTotalElements()); + assertEquals("Pagination request present=false", events.list.getFirst().getFormattedMessage()); + } + + @Test + void filterFailureRetainsCriteriaFallbackWithoutLeakingMessageOrStack() { + Collection values = new AbstractCollection<>() { + @Override public Iterator iterator() { throw new AssertionError("Not needed"); } + @Override public int size() { throw new IllegalStateException("PRIVATE_FILTER\r\nforged event\u2028payload"); } + }; + FilterUtils.initializeTemplate(mock(R2dbcEntityTemplate.class)); + var request = FilterRequest.builder().filters(new CollectionFilter(values)).build(); + var criteria = assertDoesNotThrow(() -> FilterUtils.createFilter(Entry.class, Function.identity()).getCriteria(request)); + assertTrue(criteria.isEmpty()); + assertEquals(1, events.list.size()); + var event = events.list.getFirst(); + assertEquals(Level.ERROR, event.getLevel()); + assertEquals("Unexpected error processing filter field values: type=java.lang.IllegalStateException", event.getFormattedMessage()); + assertNull(event.getThrowableProxy()); + assertFalse(event.getFormattedMessage().contains("PRIVATE_FILTER")); + } + + @Test + void disabledLoggingDoesNotRenderPaginationOrChangeResults() { + logger.setLevel(Level.OFF); + var rendered = new AtomicInteger(); + var pagination = new PaginationRequest(0, 10, null, "ASC") { + @Override public String toString() { rendered.incrementAndGet(); return "PRIVATE"; } + }; + assertEquals(0, paginatedFilter().filter(FilterRequest.builder().pagination(pagination).build()).block().getTotalElements()); + assertEquals(0, rendered.get()); + assertTrue(events.list.isEmpty()); + } + + @Test + void skippedIdRangeRetainsItsDiagnosticAndEmptyCriteria() { + FilterUtils.initializeTemplate(mock(R2dbcEntityTemplate.class)); + var ranges = new java.util.HashMap>(); + ranges.put("id", new RangeFilter.Range<>(1, 2)); + var request = FilterRequest.builder().rangeFilters(new RangeFilter(ranges)).build(); + var criteria = FilterUtils.createFilter(TestEntity.class, Function.identity()).getCriteria(request); + assertTrue(criteria.isEmpty()); + assertEquals(1, events.list.size()); + assertEquals(Level.DEBUG, events.list.getFirst().getLevel()); + assertEquals("Skipping range filter for ID field: id", events.list.getFirst().getFormattedMessage()); + } + + @SuppressWarnings("unchecked") + private FilterUtils.GenericFilter paginatedFilter() { + var template = mock(R2dbcEntityTemplate.class); + ReactiveSelect select = mock(ReactiveSelect.class); + TerminatingSelect matching = mock(TerminatingSelect.class); + when(template.select(Entry.class)).thenReturn(select); + when(select.matching(any(Query.class))).thenReturn(matching); + when(matching.all()).thenReturn(Flux.empty()); + when(matching.count()).thenReturn(Mono.just(0L)); + FilterUtils.initializeTemplate(template); + return FilterUtils.createFilter(Entry.class, Function.identity()); + } + + private record Entry(String value) {} + private record CollectionFilter(Collection values) {} +} From 6f035c4cd4ffb1ca322a4401700dadc05ec7449a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 19:54:46 -0700 Subject: [PATCH 24/44] Keep cache diagnostics free of keys and provider payloads --- .../postgres/PostgresCacheAdapter.java | 16 +-- .../PostgresCacheAdapterLoggingTest.java | 81 +++++++++++++++ .../adapter/redis/RedisCacheAdapter.java | 76 +++++++------- .../redis/RedisCacheAdapterLoggingTest.java | 98 +++++++++++++++++++ .../caffeine/CaffeineCacheAdapter.java | 62 ++++++------ .../cache/manager/FireflyCacheManager.java | 28 +++--- .../CaffeineCacheAdapterLoggingTest.java | 82 ++++++++++++++++ .../FireflyCacheManagerLoggingTest.java | 96 ++++++++++++++++++ 8 files changed, 452 insertions(+), 87 deletions(-) create mode 100644 modules/cache-postgres/src/test/java/org/fireflyframework/cache/adapter/postgres/PostgresCacheAdapterLoggingTest.java create mode 100644 modules/cache-redis/src/test/java/org/fireflyframework/cache/adapter/redis/RedisCacheAdapterLoggingTest.java create mode 100644 modules/cache/src/test/java/org/fireflyframework/cache/adapter/caffeine/CaffeineCacheAdapterLoggingTest.java create mode 100644 modules/cache/src/test/java/org/fireflyframework/cache/manager/FireflyCacheManagerLoggingTest.java diff --git a/modules/cache-postgres/src/main/java/org/fireflyframework/cache/adapter/postgres/PostgresCacheAdapter.java b/modules/cache-postgres/src/main/java/org/fireflyframework/cache/adapter/postgres/PostgresCacheAdapter.java index 776c572ef..7b689e620 100644 --- a/modules/cache-postgres/src/main/java/org/fireflyframework/cache/adapter/postgres/PostgresCacheAdapter.java +++ b/modules/cache-postgres/src/main/java/org/fireflyframework/cache/adapter/postgres/PostgresCacheAdapter.java @@ -16,6 +16,8 @@ package org.fireflyframework.cache.adapter.postgres; +import org.fireflyframework.kernel.logging.LogFields; + import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.Readable; @@ -183,8 +185,8 @@ public Mono> get(K key, Class valueType) { totalLoadTime.addAndGet(System.nanoTime() - start); return Optional.ofNullable(value); } catch (Exception e) { - log.warn("Failed to deserialize cached value for key '{}' in cache '{}': {}", - key, cacheName, e.getMessage()); + log.warn("Failed to deserialize cached value for key present={} in cache '{}': type={}", + key != null, LogFields.text(cacheName), LogFields.errorType(e)); missCount.incrementAndGet(); return Optional.empty(); } @@ -192,7 +194,7 @@ public Mono> get(K key, Class valueType) { missCount.incrementAndGet(); return Optional.empty(); })).onErrorResume(e -> { - log.warn("Error reading key '{}' from cache '{}': {}", key, cacheName, e.getMessage()); + log.warn("Error reading key present={} from cache '{}': type={}", key != null, LogFields.text(cacheName), LogFields.errorType(e)); missCount.incrementAndGet(); return Mono.just(Optional.empty()); }); @@ -295,7 +297,7 @@ public Mono evict(K key) { return removed; }) .onErrorResume(e -> { - log.warn("Error evicting key '{}' from cache '{}': {}", key, cacheName, e.getMessage()); + log.warn("Error evicting key present={} from cache '{}': type={}", key != null, LogFields.text(cacheName), LogFields.errorType(e)); return Mono.just(false); }); } @@ -333,8 +335,8 @@ public Mono exists(K key) { .next()) .defaultIfEmpty(false) .onErrorResume(e -> { - log.warn("Error checking existence of key '{}' in cache '{}': {}", - key, cacheName, e.getMessage()); + log.warn("Error checking existence of key present={} in cache '{}': type={}", + key != null, LogFields.text(cacheName), LogFields.errorType(e)); return Mono.just(false); }); } @@ -434,7 +436,7 @@ public Mono getHealth() { public void close() { // The ConnectionFactory/ConnectionPool is Spring-managed (disposed via the // bean destroy method); nothing to close here. - log.info("Closing PostgreSQL cache adapter '{}'", cacheName); + log.info("Closing PostgreSQL cache adapter '{}'", LogFields.text(cacheName)); } private Mono rowsUpdated(Statement statement) { diff --git a/modules/cache-postgres/src/test/java/org/fireflyframework/cache/adapter/postgres/PostgresCacheAdapterLoggingTest.java b/modules/cache-postgres/src/test/java/org/fireflyframework/cache/adapter/postgres/PostgresCacheAdapterLoggingTest.java new file mode 100644 index 000000000..0b32eef41 --- /dev/null +++ b/modules/cache-postgres/src/test/java/org/fireflyframework/cache/adapter/postgres/PostgresCacheAdapterLoggingTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.cache.adapter.postgres; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class PostgresCacheAdapterLoggingTest { + private static final String NAME = "cache\r\nregion\u000bvertical\fform\u0085next\u2028line\u2029paragraph"; + private final Logger logger = (Logger) LoggerFactory.getLogger(PostgresCacheAdapter.class); + private final ListAppender events = new ListAppender<>(); + private Level previousLevel; + private boolean previousAdditive; + + @BeforeEach void capture() { + previousLevel = logger.getLevel(); previousAdditive = logger.isAdditive(); + logger.setLevel(Level.DEBUG); logger.setAdditive(false); events.start(); logger.addAppender(events); + } + @AfterEach void release() { + logger.detachAppender(events); events.stop(); logger.setLevel(previousLevel); logger.setAdditive(previousAdditive); + } + private void safeEvent(Level expected, boolean error) { + assertFalse(events.list.isEmpty()); + var event = events.list.getLast(); String message = event.getFormattedMessage(); + assertEquals(expected, event.getLevel()); assertNull(event.getThrowableProxy()); + assertFalse(message.contains("PRIVATE_"), message); + assertFalse(message.matches("(?s).*[\r\n\u000b\f\u0085\u2028\u2029].*"), message); + if (error) assertTrue(message.contains("type=java.lang.IllegalStateException"), message); + } + private static final class Key { + int renders; + @Override public String toString() { renders++; return "PRIVATE_KEY\r\nforged\u2028entry"; } + } + + @Test void readEvictAndExistsErrorsKeepTheirFallbacksAndTypeOnlyWarnings() { + var factory = mock(io.r2dbc.spi.ConnectionFactory.class); var failure = new IllegalStateException("PRIVATE_MESSAGE\r\nforged"); + when(factory.create()).thenAnswer(invocation -> Mono.error(failure)); + var adapter = new PostgresCacheAdapter(NAME, factory, PostgresCacheConfig.builder().build(), mock(org.fireflyframework.cache.serialization.CacheSerializer.class)); + var key = new Key(); assertEquals(Optional.empty(), adapter.get(key).block()); safeEvent(Level.WARN, true); assertEquals(1,key.renders); + assertEquals(false, adapter.evict(key).block()); safeEvent(Level.WARN, true); assertEquals(2,key.renders); + assertEquals(false, adapter.exists(key).block()); safeEvent(Level.WARN, true); assertEquals(3,key.renders); + adapter.close(); safeEvent(Level.INFO, false); + } + @Test @SuppressWarnings({"rawtypes","unchecked"}) void deserializerFailureRetainsEmptyReadWithoutRenderingKeyAgain() { + var factory = mock(io.r2dbc.spi.ConnectionFactory.class); var connection = mock(io.r2dbc.spi.Connection.class); + var statement = mock(io.r2dbc.spi.Statement.class, RETURNS_SELF); var result = mock(io.r2dbc.spi.Result.class); var row = mock(io.r2dbc.spi.Row.class); + when(factory.create()).thenAnswer(invocation -> Mono.just(connection)); when(connection.close()).thenReturn(Mono.empty()); + when(connection.createStatement(anyString())).thenReturn(statement); when(statement.execute()).thenAnswer(invocation -> reactor.core.publisher.Flux.just(result)); + when(row.get("value")).thenReturn("serialized".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + when(result.map(any(java.util.function.BiFunction.class))).thenAnswer(invocation -> reactor.core.publisher.Flux.just(((java.util.function.BiFunction)invocation.getArgument(0)).apply(row,mock(io.r2dbc.spi.RowMetadata.class)))); + var serializer = mock(org.fireflyframework.cache.serialization.CacheSerializer.class); + when(serializer.deserialize(any(),any())).thenThrow(new IllegalStateException("PRIVATE_MESSAGE\r\nforged")); + var adapter = new PostgresCacheAdapter(NAME,factory,PostgresCacheConfig.builder().build(),serializer); var key = new Key(); + assertEquals(Optional.empty(),adapter.get(key,String.class).block()); safeEvent(Level.WARN,true); assertEquals(1,key.renders); + verify(connection).close(); + } +} diff --git a/modules/cache-redis/src/main/java/org/fireflyframework/cache/adapter/redis/RedisCacheAdapter.java b/modules/cache-redis/src/main/java/org/fireflyframework/cache/adapter/redis/RedisCacheAdapter.java index 76b36be1c..b7adf2e7b 100644 --- a/modules/cache-redis/src/main/java/org/fireflyframework/cache/adapter/redis/RedisCacheAdapter.java +++ b/modules/cache-redis/src/main/java/org/fireflyframework/cache/adapter/redis/RedisCacheAdapter.java @@ -16,6 +16,8 @@ package org.fireflyframework.cache.adapter.redis; +import org.fireflyframework.kernel.logging.LogFields; + import org.fireflyframework.cache.core.CacheAdapter; import org.fireflyframework.cache.core.CacheHealth; import org.fireflyframework.cache.core.CacheStats; @@ -107,18 +109,18 @@ public Mono> get(K key) { V deserializedValue = serializer.deserialize(value, (Class) Object.class); hitCount.incrementAndGet(); totalLoadTime.addAndGet(System.nanoTime() - startTime); - log.debug("Cache hit for key '{}' in cache '{}'", key, cacheName); + log.debug("Cache hit for key present={} in cache '{}'", key != null, LogFields.text(cacheName)); return Optional.of(deserializedValue); } catch (Exception e) { - log.warn("Failed to deserialize value for key '{}' in cache '{}': {}", - key, cacheName, e.getMessage()); + log.warn("Failed to deserialize value for key present={} in cache '{}': type={}", + key != null, LogFields.text(cacheName), LogFields.errorType(e)); missCount.incrementAndGet(); return Optional.empty(); } }) .switchIfEmpty(Mono.fromCallable(() -> { missCount.incrementAndGet(); - log.debug("Cache miss for key '{}' in cache '{}'", key, cacheName); + log.debug("Cache miss for key present={} in cache '{}'", key != null, LogFields.text(cacheName)); return Optional.empty(); })); }).flatMap(mono -> mono); @@ -138,18 +140,18 @@ public Mono> get(K key, Class valueType) { V deserializedValue = serializer.deserialize(value, valueType); hitCount.incrementAndGet(); totalLoadTime.addAndGet(System.nanoTime() - startTime); - log.debug("Cache hit for key '{}' in cache '{}'", key, cacheName); + log.debug("Cache hit for key present={} in cache '{}'", key != null, LogFields.text(cacheName)); return Optional.of(deserializedValue); } catch (Exception e) { - log.warn("Failed to deserialize value for key '{}' in cache '{}': {}", - key, cacheName, e.getMessage()); + log.warn("Failed to deserialize value for key present={} in cache '{}': type={}", + key != null, LogFields.text(cacheName), LogFields.errorType(e)); missCount.incrementAndGet(); return Optional.empty(); } }) .switchIfEmpty(Mono.fromCallable(() -> { missCount.incrementAndGet(); - log.debug("Cache miss for key '{}' in cache '{}'", key, cacheName); + log.debug("Cache miss for key present={} in cache '{}'", key != null, LogFields.text(cacheName)); return Optional.empty(); })); }).flatMap(mono -> mono); @@ -175,11 +177,11 @@ public Mono put(K key, V value) { return operation.doOnSuccess(unused -> { putCount.incrementAndGet(); - log.debug("Put value in cache '{}' for key '{}'", cacheName, key); + log.debug("Put value in cache '{}' for key present={}", LogFields.text(cacheName), key != null); }); } catch (Exception e) { - log.error("Error putting value in cache '{}' for key '{}': {}", - cacheName, key, e.getMessage(), e); + log.error("Error putting value in cache '{}' for key present={}: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); throw new RuntimeException("Failed to put value in cache", e); } }).flatMap(mono -> mono); @@ -197,11 +199,11 @@ public Mono put(K key, V value, Duration ttl) { .then() .doOnSuccess(unused -> { putCount.incrementAndGet(); - log.debug("Put value in cache '{}' for key '{}' with TTL {}", cacheName, key, ttl); + log.debug("Put value in cache '{}' for key present={} with TTL {}", LogFields.text(cacheName), key != null, ttl); }); } catch (Exception e) { - log.error("Error putting value in cache '{}' for key '{}' with TTL: {}", - cacheName, key, e.getMessage(), e); + log.error("Error putting value in cache '{}' for key present={} with TTL: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); throw new RuntimeException("Failed to put value in cache with TTL", e); } }).flatMap(mono -> mono); @@ -226,14 +228,14 @@ public Mono putIfAbsent(K key, V value) { return operation.doOnNext(success -> { if (success) { putCount.incrementAndGet(); - log.debug("Put new value in cache '{}' for key '{}'", cacheName, key); + log.debug("Put new value in cache '{}' for key present={}", LogFields.text(cacheName), key != null); } else { - log.debug("Key '{}' already exists in cache '{}'", key, cacheName); + log.debug("Key present={} already exists in cache '{}'", key != null, LogFields.text(cacheName)); } }); } catch (Exception e) { - log.error("Error in putIfAbsent for cache '{}' and key '{}': {}", - cacheName, key, e.getMessage(), e); + log.error("Error in putIfAbsent for cache '{}' and key present={}: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); throw new RuntimeException("Failed to put value if absent", e); } }).flatMap(mono -> mono); @@ -251,15 +253,15 @@ public Mono putIfAbsent(K key, V value, Duration ttl) { .doOnNext(success -> { if (success) { putCount.incrementAndGet(); - log.debug("Put new value in cache '{}' for key '{}' with TTL {}", - cacheName, key, ttl); + log.debug("Put new value in cache '{}' for key present={} with TTL {}", + LogFields.text(cacheName), key != null, ttl); } else { - log.debug("Key '{}' already exists in cache '{}'", key, cacheName); + log.debug("Key present={} already exists in cache '{}'", key != null, LogFields.text(cacheName)); } }); } catch (Exception e) { - log.error("Error in putIfAbsent with TTL for cache '{}' and key '{}': {}", - cacheName, key, e.getMessage(), e); + log.error("Error in putIfAbsent with TTL for cache '{}' and key present={}: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); throw new RuntimeException("Failed to put value if absent with TTL", e); } }).flatMap(mono -> mono); @@ -273,12 +275,12 @@ public Mono evict(K key) { if (existed) { evictionCount.incrementAndGet(); } - log.debug("Evicted key '{}' from cache '{}': {}", key, cacheName, existed); + log.debug("Evicted key present={} from cache '{}': {}", key != null, LogFields.text(cacheName), existed); return existed; }) .onErrorResume(e -> { - log.error("Error evicting key '{}' from cache '{}': {}", - key, cacheName, e.getMessage(), e); + log.error("Error evicting key present={} from cache '{}': type={}", + key != null, LogFields.text(cacheName), LogFields.errorType(e)); return Mono.just(false); }); } @@ -294,11 +296,11 @@ public Mono clear() { }) .doOnNext(deletedCount -> { evictionCount.addAndGet(deletedCount); - log.debug("Cleared cache '{}', deleted {} keys", cacheName, deletedCount); + log.debug("Cleared cache '{}', deleted {} keys", LogFields.text(cacheName), deletedCount); }) .then() .onErrorResume(e -> { - log.error("Error clearing cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error clearing cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); return Mono.empty(); }); } @@ -307,8 +309,8 @@ public Mono clear() { public Mono exists(K key) { return redisTemplate.hasKey(buildKey(key)) .onErrorResume(e -> { - log.warn("Error checking existence of key '{}' in cache '{}': {}", - key, cacheName, e.getMessage()); + log.warn("Error checking existence of key present={} in cache '{}': type={}", + key != null, LogFields.text(cacheName), LogFields.errorType(e)); return Mono.just(false); }); } @@ -322,7 +324,7 @@ public Mono> keys() { .map(key -> (K) key) .collect(Collectors.toSet())) .onErrorResume(e -> { - log.error("Error getting keys from cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error getting keys from cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); return Mono.just(Set.of()); }); } @@ -332,7 +334,7 @@ public Mono size() { return getKeys() .map(keys -> (long) keys.size()) .onErrorResume(e -> { - log.error("Error getting size of cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error getting size of cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); return Mono.just(0L); }); } @@ -435,8 +437,8 @@ public Mono getHealth() { }) .onErrorResume(infoError -> { // If we can't get info, still return healthy but without details - log.warn("Could not get Redis info for cache '{}': {}", - cacheName, infoError.getMessage()); + log.warn("Could not get Redis info for cache '{}': type={}", + LogFields.text(cacheName), LogFields.errorType(infoError)); return Mono.just(CacheHealth.builder() .status("UP") .cacheType(CacheType.REDIS) @@ -456,7 +458,7 @@ public Mono getHealth() { }); }) .onErrorResume(e -> { - log.error("Health check failed for cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Health check failed for cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); return Mono.just(CacheHealth.unhealthy(CacheType.REDIS, cacheName, "Health check failed: " + e.getMessage(), e)); }); @@ -466,10 +468,10 @@ public Mono getHealth() { @Override public void close() { try { - log.info("Closing Redis cache adapter '{}'", cacheName); + log.info("Closing Redis cache adapter '{}'", LogFields.text(cacheName)); // Connection factory is typically managed by Spring, so we don't close it here } catch (Exception e) { - log.error("Error closing cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error closing cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); } } diff --git a/modules/cache-redis/src/test/java/org/fireflyframework/cache/adapter/redis/RedisCacheAdapterLoggingTest.java b/modules/cache-redis/src/test/java/org/fireflyframework/cache/adapter/redis/RedisCacheAdapterLoggingTest.java new file mode 100644 index 000000000..b9c1c9beb --- /dev/null +++ b/modules/cache-redis/src/test/java/org/fireflyframework/cache/adapter/redis/RedisCacheAdapterLoggingTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.cache.adapter.redis; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class RedisCacheAdapterLoggingTest { + private static final String NAME = "cache\r\nregion\u000bvertical\fform\u0085next\u2028line\u2029paragraph"; + private final Logger logger = (Logger) LoggerFactory.getLogger(RedisCacheAdapter.class); + private final ListAppender events = new ListAppender<>(); + private Level previousLevel; + private boolean previousAdditive; + + @BeforeEach void capture() { + previousLevel = logger.getLevel(); previousAdditive = logger.isAdditive(); + logger.setLevel(Level.DEBUG); logger.setAdditive(false); events.start(); logger.addAppender(events); + } + @AfterEach void release() { + logger.detachAppender(events); events.stop(); logger.setLevel(previousLevel); logger.setAdditive(previousAdditive); + } + private void safeEvent(Level expected, boolean error) { + assertFalse(events.list.isEmpty()); + var event = events.list.getLast(); String message = event.getFormattedMessage(); + assertEquals(expected, event.getLevel()); assertNull(event.getThrowableProxy()); + assertFalse(message.contains("PRIVATE_"), message); + assertFalse(message.matches("(?s).*[\r\n\u000b\f\u0085\u2028\u2029].*"), message); + if (error) assertTrue(message.contains("type=java.lang.IllegalStateException"), message); + } + private static final class Key { + int renders; + @Override public String toString() { renders++; return "PRIVATE_KEY\r\nforged\u2028entry"; } + } + + private record Fixture(RedisCacheAdapter adapter, org.springframework.data.redis.core.ReactiveValueOperations values, org.fireflyframework.cache.serialization.CacheSerializer serializer) { } + @SuppressWarnings("unchecked") private Fixture fixture() { + var template = mock(org.springframework.data.redis.core.ReactiveRedisTemplate.class); + var values = mock(org.springframework.data.redis.core.ReactiveValueOperations.class); + var serializer = mock(org.fireflyframework.cache.serialization.CacheSerializer.class); + when(template.opsForValue()).thenReturn(values); + return new Fixture(new RedisCacheAdapter(NAME, template, mock(org.springframework.data.redis.connection.ReactiveRedisConnectionFactory.class), RedisCacheConfig.builder().build(), serializer), values, serializer); + } + @Test void bothReadOverloadsKeepHitsWithoutExtraKeyRendering() { + for (boolean typed : new boolean[]{false,true}) { + events.list.clear(); var f = fixture(); var key = new Key(); + when(f.values().get(anyString())).thenReturn(Mono.just("serialized")); + when(f.serializer().deserialize(any(), any())).thenReturn("value"); + assertEquals(Optional.of("value"), typed ? f.adapter().get(key, String.class).block() : f.adapter().get(key).block()); + safeEvent(Level.DEBUG, false); assertEquals(1, key.renders); + assertTrue(events.list.getLast().getFormattedMessage().contains("cache\\r\\nregion")); + } + } + @Test void bothReadOverloadsKeepMissesWithoutExtraKeyRendering() { + for (boolean typed : new boolean[]{false,true}) { + events.list.clear(); var f = fixture(); var key = new Key(); when(f.values().get(anyString())).thenReturn(Mono.empty()); + assertEquals(Optional.empty(), typed ? f.adapter().get(key, String.class).block() : f.adapter().get(key).block()); + safeEvent(Level.DEBUG, false); assertEquals(1, key.renders); + } + } + @Test void bothDeserializerFailuresKeepEmptyFallbackAndTypeOnlyWarnings() { + for (boolean typed : new boolean[]{false,true}) { + events.list.clear(); var f = fixture(); var key = new Key(); when(f.values().get(anyString())).thenReturn(Mono.just("serialized")); + when(f.serializer().deserialize(any(), any())).thenThrow(new IllegalStateException("PRIVATE_MESSAGE\r\nforged")); + assertEquals(Optional.empty(), typed ? f.adapter().get(key, String.class).block() : f.adapter().get(key).block()); + safeEvent(Level.WARN, true); assertEquals(1, key.renders); + } + } + @Test void serializationFailureRetainsWrappedCauseWithoutRenderingKey() { + var f = fixture(); var key = new Key(); var failure = new IllegalStateException("PRIVATE_MESSAGE\u2028forged"); + when(f.serializer().serialize(any())).thenThrow(failure); + assertSame(failure, assertThrows(RuntimeException.class, () -> f.adapter().put(key, "value").block()).getCause()); + safeEvent(Level.ERROR, true); assertEquals(0, key.renders); + } +} diff --git a/modules/cache/src/main/java/org/fireflyframework/cache/adapter/caffeine/CaffeineCacheAdapter.java b/modules/cache/src/main/java/org/fireflyframework/cache/adapter/caffeine/CaffeineCacheAdapter.java index 55b33d5e6..d9f5b07bc 100644 --- a/modules/cache/src/main/java/org/fireflyframework/cache/adapter/caffeine/CaffeineCacheAdapter.java +++ b/modules/cache/src/main/java/org/fireflyframework/cache/adapter/caffeine/CaffeineCacheAdapter.java @@ -16,6 +16,8 @@ package org.fireflyframework.cache.adapter.caffeine; +import org.fireflyframework.kernel.logging.LogFields; + import org.fireflyframework.cache.core.CacheAdapter; import org.fireflyframework.cache.core.CacheHealth; import org.fireflyframework.cache.core.CacheStats; @@ -129,8 +131,8 @@ public Mono> get(K key) { V value = (V) cache.getIfPresent(prefixedKey); return Optional.ofNullable(value); } catch (Exception e) { - log.warn("Error getting value from cache '{}' for key '{}': {}", - cacheName, key, e.getMessage()); + log.warn("Error getting value from cache '{}' for key present={}: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); return Optional.empty(); } }); @@ -152,10 +154,10 @@ public Mono put(K key, V value) { cache.put(prefixedKey, value); // Remove any custom expiration time expirationTimes.remove(prefixedKey); - log.debug("Put value in cache '{}' for key '{}'", cacheName, key); + log.debug("Put value in cache '{}' for key present={}", LogFields.text(cacheName), key != null); } catch (Exception e) { - log.error("Error putting value in cache '{}' for key '{}': {}", - cacheName, key, e.getMessage(), e); + log.error("Error putting value in cache '{}' for key present={}: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); throw new RuntimeException("Failed to put value in cache", e); } }); @@ -169,10 +171,10 @@ public Mono put(K key, V value, Duration ttl) { cache.put(prefixedKey, value); // Track custom expiration time expirationTimes.put(prefixedKey, Instant.now().plus(ttl)); - log.debug("Put value in cache '{}' for key '{}' with TTL {}", cacheName, key, ttl); + log.debug("Put value in cache '{}' for key present={} with TTL {}", LogFields.text(cacheName), key != null, ttl); } catch (Exception e) { - log.error("Error putting value in cache '{}' for key '{}' with TTL: {}", - cacheName, key, e.getMessage(), e); + log.error("Error putting value in cache '{}' for key present={} with TTL: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); throw new RuntimeException("Failed to put value in cache with TTL", e); } }); @@ -196,15 +198,15 @@ public Mono putIfAbsent(K key, V value) { if (existingValue == null) { cache.put(prefixedKey, value); expirationTimes.remove(prefixedKey); // No custom TTL - log.debug("Put new value in cache '{}' for key '{}'", cacheName, key); + log.debug("Put new value in cache '{}' for key present={}", LogFields.text(cacheName), key != null); return true; } else { - log.debug("Key '{}' already exists in cache '{}'", key, cacheName); + log.debug("Key present={} already exists in cache '{}'", key != null, LogFields.text(cacheName)); return false; } } catch (Exception e) { - log.error("Error in putIfAbsent for cache '{}' and key '{}': {}", - cacheName, key, e.getMessage(), e); + log.error("Error in putIfAbsent for cache '{}' and key present={}: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); throw new RuntimeException("Failed to put value if absent", e); } }); @@ -228,16 +230,16 @@ public Mono putIfAbsent(K key, V value, Duration ttl) { if (existingValue == null) { cache.put(prefixedKey, value); expirationTimes.put(prefixedKey, Instant.now().plus(ttl)); - log.debug("Put new value in cache '{}' for key '{}' with TTL {}", - cacheName, key, ttl); + log.debug("Put new value in cache '{}' for key present={} with TTL {}", + LogFields.text(cacheName), key != null, ttl); return true; } else { - log.debug("Key '{}' already exists in cache '{}'", key, cacheName); + log.debug("Key present={} already exists in cache '{}'", key != null, LogFields.text(cacheName)); return false; } } catch (Exception e) { - log.error("Error in putIfAbsent with TTL for cache '{}' and key '{}': {}", - cacheName, key, e.getMessage(), e); + log.error("Error in putIfAbsent with TTL for cache '{}' and key present={}: type={}", + LogFields.text(cacheName), key != null, LogFields.errorType(e)); throw new RuntimeException("Failed to put value if absent with TTL", e); } }); @@ -251,11 +253,11 @@ public Mono evict(K key) { boolean existed = cache.getIfPresent(prefixedKey) != null || expirationTimes.containsKey(prefixedKey); cache.invalidate(prefixedKey); expirationTimes.remove(prefixedKey); - log.debug("Evicted key '{}' from cache '{}': {}", key, cacheName, existed); + log.debug("Evicted key present={} from cache '{}': {}", key != null, LogFields.text(cacheName), existed); return existed; } catch (Exception e) { - log.error("Error evicting key '{}' from cache '{}': {}", - key, cacheName, e.getMessage(), e); + log.error("Error evicting key present={} from cache '{}': type={}", + key != null, LogFields.text(cacheName), LogFields.errorType(e)); throw new RuntimeException("Failed to evict key from cache", e); } }); @@ -267,9 +269,9 @@ public Mono clear() { try { cache.invalidateAll(); expirationTimes.clear(); - log.debug("Cleared cache '{}'", cacheName); + log.debug("Cleared cache '{}'", LogFields.text(cacheName)); } catch (Exception e) { - log.error("Error clearing cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error clearing cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); throw new RuntimeException("Failed to clear cache", e); } }); @@ -291,8 +293,8 @@ public Mono exists(K key) { return cache.getIfPresent(prefixedKey) != null; } catch (Exception e) { - log.warn("Error checking existence of key '{}' in cache '{}': {}", - key, cacheName, e.getMessage()); + log.warn("Error checking existence of key present={} in cache '{}': type={}", + key != null, LogFields.text(cacheName), LogFields.errorType(e)); return false; } }); @@ -313,7 +315,7 @@ public Mono> keys() { return unprefixedKeys; } catch (Exception e) { - log.error("Error getting keys from cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error getting keys from cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); throw new RuntimeException("Failed to get cache keys", e); } }); @@ -327,7 +329,7 @@ public Mono size() { cleanupExpiredKeys(); return cache.estimatedSize(); } catch (Exception e) { - log.error("Error getting size of cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error getting size of cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); throw new RuntimeException("Failed to get cache size", e); } }); @@ -353,7 +355,7 @@ public Mono getStats() { .cacheName(cacheName) .build(); } catch (Exception e) { - log.error("Error getting stats for cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error getting stats for cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); return CacheStats.empty(CacheType.CAFFEINE, cacheName); } }); @@ -395,7 +397,7 @@ public Mono getHealth() { "Health check failed: test value mismatch", null); } } catch (Exception e) { - log.error("Health check failed for cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Health check failed for cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); return CacheHealth.unhealthy(CacheType.CAFFEINE, cacheName, "Health check failed: " + e.getMessage(), e); } @@ -408,9 +410,9 @@ public void close() { cache.invalidateAll(); cache.cleanUp(); expirationTimes.clear(); - log.info("Closed Caffeine cache adapter '{}'", cacheName); + log.info("Closed Caffeine cache adapter '{}'", LogFields.text(cacheName)); } catch (Exception e) { - log.error("Error closing cache '{}': {}", cacheName, e.getMessage(), e); + log.error("Error closing cache '{}': type={}", LogFields.text(cacheName), LogFields.errorType(e)); } } diff --git a/modules/cache/src/main/java/org/fireflyframework/cache/manager/FireflyCacheManager.java b/modules/cache/src/main/java/org/fireflyframework/cache/manager/FireflyCacheManager.java index 8233562ee..3557b2302 100644 --- a/modules/cache/src/main/java/org/fireflyframework/cache/manager/FireflyCacheManager.java +++ b/modules/cache/src/main/java/org/fireflyframework/cache/manager/FireflyCacheManager.java @@ -16,6 +16,8 @@ package org.fireflyframework.cache.manager; +import org.fireflyframework.kernel.logging.LogFields; + import org.fireflyframework.cache.core.CacheAdapter; import org.fireflyframework.cache.core.CacheHealth; import org.fireflyframework.cache.core.CacheStats; @@ -79,7 +81,7 @@ public FireflyCacheManager(CacheAdapter primaryCache, CacheAdapter fallbackCache public Mono> get(K key) { return getActiveCache().get(key) .onErrorResume(e -> { - log.error("Error during get operation for key {}: {}", key, e.getMessage()); + log.error("Error during get operation for key present={}: type={}", key != null, LogFields.errorType(e)); return Mono.just(Optional.empty()); }); } @@ -88,7 +90,7 @@ public Mono> get(K key) { public Mono> get(K key, Class valueType) { return getActiveCache().get(key, valueType) .onErrorResume(e -> { - log.error("Error during get operation for key {}: {}", key, e.getMessage()); + log.error("Error during get operation for key present={}: type={}", key != null, LogFields.errorType(e)); return Mono.just(Optional.empty()); }); } @@ -97,7 +99,7 @@ public Mono> get(K key, Class valueType) { public Mono put(K key, V value) { return getActiveCache().put(key, value) .onErrorResume(e -> { - log.error("Error during put operation for key {}: {}", key, e.getMessage()); + log.error("Error during put operation for key present={}: type={}", key != null, LogFields.errorType(e)); return Mono.empty(); }); } @@ -106,7 +108,7 @@ public Mono put(K key, V value) { public Mono put(K key, V value, Duration ttl) { return getActiveCache().put(key, value, ttl) .onErrorResume(e -> { - log.error("Error during put operation for key {}: {}", key, e.getMessage()); + log.error("Error during put operation for key present={}: type={}", key != null, LogFields.errorType(e)); return Mono.empty(); }); } @@ -115,7 +117,7 @@ public Mono put(K key, V value, Duration ttl) { public Mono putIfAbsent(K key, V value) { return getActiveCache().putIfAbsent(key, value) .onErrorResume(e -> { - log.error("Error during putIfAbsent operation for key {}: {}", key, e.getMessage()); + log.error("Error during putIfAbsent operation for key present={}: type={}", key != null, LogFields.errorType(e)); return Mono.just(false); }); } @@ -124,7 +126,7 @@ public Mono putIfAbsent(K key, V value) { public Mono putIfAbsent(K key, V value, Duration ttl) { return getActiveCache().putIfAbsent(key, value, ttl) .onErrorResume(e -> { - log.error("Error during putIfAbsent operation for key {}: {}", key, e.getMessage()); + log.error("Error during putIfAbsent operation for key present={}: type={}", key != null, LogFields.errorType(e)); return Mono.just(false); }); } @@ -133,7 +135,7 @@ public Mono putIfAbsent(K key, V value, Duration ttl) { public Mono evict(K key) { return getActiveCache().evict(key) .onErrorResume(e -> { - log.error("Error during evict operation for key {}: {}", key, e.getMessage()); + log.error("Error during evict operation for key present={}: type={}", key != null, LogFields.errorType(e)); return Mono.just(false); }); } @@ -142,7 +144,7 @@ public Mono evict(K key) { public Mono clear() { return getActiveCache().clear() .onErrorResume(e -> { - log.error("Error clearing cache: {}", e.getMessage(), e); + log.error("Error clearing cache: type={}", LogFields.errorType(e)); return Mono.empty(); }); } @@ -151,7 +153,7 @@ public Mono clear() { public Mono exists(K key) { return getActiveCache().exists(key) .onErrorResume(e -> { - log.error("Error during exists operation for key {}: {}", key, e.getMessage()); + log.error("Error during exists operation for key present={}: type={}", key != null, LogFields.errorType(e)); return Mono.just(false); }); } @@ -160,7 +162,7 @@ public Mono exists(K key) { public Mono> keys() { return getActiveCache().keys() .onErrorResume(e -> { - log.error("Error getting keys: {}", e.getMessage(), e); + log.error("Error getting keys: type={}", LogFields.errorType(e)); return Mono.just(Set.of()); }); } @@ -169,7 +171,7 @@ public Mono> keys() { public Mono size() { return getActiveCache().size() .onErrorResume(e -> { - log.error("Error getting size: {}", e.getMessage(), e); + log.error("Error getting size: type={}", LogFields.errorType(e)); return Mono.just(0L); }); } @@ -212,7 +214,7 @@ public void close() { primaryCache.close(); log.debug("Closed primary cache"); } catch (Exception e) { - log.error("Error closing primary cache: {}", e.getMessage(), e); + log.error("Error closing primary cache: type={}", LogFields.errorType(e)); } if (fallbackCache != null) { @@ -220,7 +222,7 @@ public void close() { fallbackCache.close(); log.debug("Closed fallback cache"); } catch (Exception e) { - log.error("Error closing fallback cache: {}", e.getMessage(), e); + log.error("Error closing fallback cache: type={}", LogFields.errorType(e)); } } diff --git a/modules/cache/src/test/java/org/fireflyframework/cache/adapter/caffeine/CaffeineCacheAdapterLoggingTest.java b/modules/cache/src/test/java/org/fireflyframework/cache/adapter/caffeine/CaffeineCacheAdapterLoggingTest.java new file mode 100644 index 000000000..d9ba17717 --- /dev/null +++ b/modules/cache/src/test/java/org/fireflyframework/cache/adapter/caffeine/CaffeineCacheAdapterLoggingTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.cache.adapter.caffeine; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class CaffeineCacheAdapterLoggingTest { + private static final String NAME = "cache\r\nregion\u000bvertical\fform\u0085next\u2028line\u2029paragraph"; + private final Logger logger = (Logger) LoggerFactory.getLogger(CaffeineCacheAdapter.class); + private final ListAppender events = new ListAppender<>(); + private Level previousLevel; + private boolean previousAdditive; + + @BeforeEach void capture() { + previousLevel = logger.getLevel(); previousAdditive = logger.isAdditive(); + logger.setLevel(Level.DEBUG); logger.setAdditive(false); events.start(); logger.addAppender(events); + } + @AfterEach void release() { + logger.detachAppender(events); events.stop(); logger.setLevel(previousLevel); logger.setAdditive(previousAdditive); + } + private void safeEvent(Level expected, boolean error) { + assertFalse(events.list.isEmpty()); + var event = events.list.getLast(); String message = event.getFormattedMessage(); + assertEquals(expected, event.getLevel()); assertNull(event.getThrowableProxy()); + assertFalse(message.contains("PRIVATE_"), message); + assertFalse(message.matches("(?s).*[\r\n\u000b\f\u0085\u2028\u2029].*"), message); + if (error) assertTrue(message.contains("type=java.lang.IllegalStateException"), message); + } + private static final class Key { + int renders; + @Override public String toString() { renders++; return "PRIVATE_KEY\r\nforged\u2028entry"; } + } + + private CaffeineCacheAdapter adapter() { return new CaffeineCacheAdapter(NAME, CaffeineCacheConfig.builder().maximumSize(10L).build()); } + @Test void successfulOperationsLogKeyPresenceAndEscapeOnlyTheCacheName() { + var cache = adapter(); var key = new Key(); cache.put(key, "value").block(); + safeEvent(Level.DEBUG, false); assertEquals(1, key.renders); + assertTrue(events.list.getLast().getFormattedMessage().contains("cache\\r\\nregion")); + assertEquals(Optional.of("value"), cache.get(key).block()); + cache.close(); safeEvent(Level.INFO, false); + } + @Test void keyFailureKeepsEmptyReadAndOneBusinessRenderingWithoutErrorPayload() { + var renders = new java.util.concurrent.atomic.AtomicInteger(); + Object key = new Object() { @Override public String toString() { if (renders.incrementAndGet() == 1) throw new IllegalStateException("PRIVATE_MESSAGE\r\nforged"); return "PRIVATE_KEY"; } }; + assertEquals(Optional.empty(), adapter().get(key).block()); safeEvent(Level.WARN, true); assertEquals(1, renders.get()); + } + @Test void putFailurePreservesOriginalWrappedCauseWithoutLoggingIt() { + var failure = new IllegalStateException("PRIVATE_MESSAGE\u2028forged"); + Object key = new Object() { @Override public String toString() { throw failure; } }; + var error = assertThrows(RuntimeException.class, () -> adapter().put(key, "value").block()); + assertSame(failure, error.getCause()); safeEvent(Level.ERROR, true); + } + @Test void disabledLoggingAddsNoGenericKeyRendering() { + logger.setLevel(Level.OFF); var key = new Key(); adapter().put(key, "value").block(); + assertEquals(1, key.renders); assertTrue(events.list.isEmpty()); + } +} diff --git a/modules/cache/src/test/java/org/fireflyframework/cache/manager/FireflyCacheManagerLoggingTest.java b/modules/cache/src/test/java/org/fireflyframework/cache/manager/FireflyCacheManagerLoggingTest.java new file mode 100644 index 000000000..60b5c03e6 --- /dev/null +++ b/modules/cache/src/test/java/org/fireflyframework/cache/manager/FireflyCacheManagerLoggingTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.cache.manager; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class FireflyCacheManagerLoggingTest { + private static final String NAME = "cache\r\nregion\u000bvertical\fform\u0085next\u2028line\u2029paragraph"; + private final Logger logger = (Logger) LoggerFactory.getLogger(FireflyCacheManager.class); + private final ListAppender events = new ListAppender<>(); + private Level previousLevel; + private boolean previousAdditive; + + @BeforeEach void capture() { + previousLevel = logger.getLevel(); previousAdditive = logger.isAdditive(); + logger.setLevel(Level.DEBUG); logger.setAdditive(false); events.start(); logger.addAppender(events); + } + @AfterEach void release() { + logger.detachAppender(events); events.stop(); logger.setLevel(previousLevel); logger.setAdditive(previousAdditive); + } + private void safeEvent(Level expected, boolean error) { + assertFalse(events.list.isEmpty()); + var event = events.list.getLast(); String message = event.getFormattedMessage(); + assertEquals(expected, event.getLevel()); assertNull(event.getThrowableProxy()); + assertFalse(message.contains("PRIVATE_"), message); + assertFalse(message.matches("(?s).*[\r\n\u000b\f\u0085\u2028\u2029].*"), message); + if (error) assertTrue(message.contains("type=java.lang.IllegalStateException"), message); + } + private static final class Key { + int renders; + @Override public String toString() { renders++; return "PRIVATE_KEY\r\nforged\u2028entry"; } + } + + private org.fireflyframework.cache.core.CacheAdapter primary() { + var cache = mock(org.fireflyframework.cache.core.CacheAdapter.class); + when(cache.isAvailable()).thenReturn(true); return cache; + } + @Test void genericGetErrorsKeepEmptyFallbackWithoutRenderingKeysOrPayload() { + var primary = primary(); var key = new Key(); + when(primary.get(key)).thenReturn(Mono.error(new IllegalStateException("PRIVATE_MESSAGE\r\nforged"))); + assertEquals(Optional.empty(), new FireflyCacheManager(primary).get(key).block()); + safeEvent(Level.ERROR, true); assertEquals(0, key.renders); + } + @Test void typedGetAndOtherFallbacksRetainResultsAndErrorLevels() { + var primary = primary(); var key = new Key(); var failure = new IllegalStateException("PRIVATE_MESSAGE\u2028forged"); + when(primary.get(key, String.class)).thenReturn(Mono.error(failure)); + when(primary.put(key, "value")).thenReturn(Mono.error(failure)); + when(primary.evict(key)).thenReturn(Mono.error(failure)); + when(primary.exists(key)).thenReturn(Mono.error(failure)); + when(primary.clear()).thenReturn(Mono.error(failure)); + when(primary.keys()).thenReturn(Mono.error(failure)); + when(primary.size()).thenReturn(Mono.error(failure)); + var manager = new FireflyCacheManager(primary); + assertEquals(Optional.empty(), manager.get(key, String.class).block()); safeEvent(Level.ERROR, true); + assertNull(manager.put(key, "value").block()); safeEvent(Level.ERROR, true); + assertEquals(false, manager.evict(key).block()); safeEvent(Level.ERROR, true); + assertEquals(false, manager.exists(key).block()); safeEvent(Level.ERROR, true); + assertNull(manager.clear().block()); safeEvent(Level.ERROR, true); + assertEquals(java.util.Set.of(), manager.keys().block()); safeEvent(Level.ERROR, true); + assertEquals(0L, manager.size().block()); safeEvent(Level.ERROR, true); + assertEquals(0, key.renders); + } + @Test void disabledLogsDoNotReadErrorPayloadOrRenderTheGenericKey() { + logger.setLevel(Level.OFF); var primary = primary(); var key = new Key(); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + var error = new IllegalStateException() { @Override public String getMessage() { reads.incrementAndGet(); return "PRIVATE_MESSAGE"; } }; + when(primary.get(key)).thenReturn(Mono.error(error)); + assertEquals(Optional.empty(), new FireflyCacheManager(primary).get(key).block()); + assertEquals(0, key.renders); assertEquals(0, reads.get()); assertTrue(events.list.isEmpty()); + } +} From 71d4d843ba86111145ed6f8157c749f01337c61a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 25 Oct 2026 22:13:53 -0700 Subject: [PATCH 25/44] Escape webhook metric diagnostics without changing telemetry --- .../core/metrics/WebhookMetricsService.java | 13 +- .../WebhookMetricsServiceLoggingTest.java | 122 ++++++++++++++++++ 2 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/metrics/WebhookMetricsServiceLoggingTest.java diff --git a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/metrics/WebhookMetricsService.java b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/metrics/WebhookMetricsService.java index 331fe2497..5fed636f7 100644 --- a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/metrics/WebhookMetricsService.java +++ b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/metrics/WebhookMetricsService.java @@ -18,6 +18,7 @@ import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.observability.metrics.FireflyMetricsSupport; import org.springframework.stereotype.Service; @@ -38,33 +39,33 @@ public WebhookMetricsService(MeterRegistry meterRegistry) { public void recordWebhookReceived(String providerName) { counter("received", "provider", providerName).increment(); - log.debug("Recorded webhook received for provider: {}", providerName); + log.debug("Recorded webhook received for provider: {}", LogFields.text(providerName)); } public void recordWebhookPublished(String providerName) { counter("published", "provider", providerName).increment(); - log.debug("Recorded webhook published for provider: {}", providerName); + log.debug("Recorded webhook published for provider: {}", LogFields.text(providerName)); } public void recordWebhookFailed(String providerName, String errorType) { counter("failed", "provider", providerName, "error.type", errorType).increment(); - log.debug("Recorded webhook failure for provider: {}, error: {}", providerName, errorType); + log.debug("Recorded webhook failure for provider: {}, error: {}", LogFields.text(providerName), LogFields.text(errorType)); } public void recordWebhookRejected(String providerName, String reason) { counter("rejected", "provider", providerName, "reason", reason).increment(); - log.debug("Recorded webhook rejection for provider: {}, reason: {}", providerName, reason); + log.debug("Recorded webhook rejection for provider: {}, reason: {}", LogFields.text(providerName), LogFields.text(reason)); } public void recordDuplicateWebhook(String providerName) { counter("duplicates", "provider", providerName).increment(); - log.debug("Recorded duplicate webhook for provider: {}", providerName); + log.debug("Recorded duplicate webhook for provider: {}", LogFields.text(providerName)); } public void recordProcessingTime(String providerName, Instant startTime) { Duration duration = Duration.between(startTime, Instant.now()); timer("processing.time", "provider", providerName).record(duration); - log.debug("Recorded processing time for provider: {} - {}ms", providerName, duration.toMillis()); + log.debug("Recorded processing time for provider: {} - {}ms", LogFields.text(providerName), duration.toMillis()); } public void recordPayloadSize(String providerName, long sizeBytes) { diff --git a/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/metrics/WebhookMetricsServiceLoggingTest.java b/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/metrics/WebhookMetricsServiceLoggingTest.java new file mode 100644 index 000000000..1f45d7c48 --- /dev/null +++ b/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/metrics/WebhookMetricsServiceLoggingTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.webhooks.core.metrics; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +class WebhookMetricsServiceLoggingTest { + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final WebhookMetricsService service = new WebhookMetricsService(registry); + private final Logger logger = (Logger) LoggerFactory.getLogger(WebhookMetricsService.class); + private final ListAppender events = new ListAppender<>(); + private Level previousLevel; + private boolean previousAdditivity; + + @BeforeEach + void capture() { + previousLevel = logger.getLevel(); + previousAdditivity = logger.isAdditive(); + logger.setLevel(Level.DEBUG); + logger.setAdditive(false); + events.start(); + logger.addAppender(events); + } + + @AfterEach + void restore() { + logger.detachAppender(events); + events.stop(); + logger.setLevel(previousLevel); + logger.setAdditive(previousAdditivity); + registry.close(); + } + + @Test + void providerDiagnosticsStaySingleLineWhileRealMetricLabelsRemainUnchanged() { + String provider = "sandbox\r\nprovider"; + recordAll(provider); + assertEquals(6, events.list.size()); + for (var event : events.list) { + assertEquals(Level.DEBUG, event.getLevel()); + assertNull(event.getThrowableProxy()); + assertTrue(event.getFormattedMessage().contains("sandbox\\r\\nprovider")); + assertFalse(event.getFormattedMessage().contains("\r")); + assertFalse(event.getFormattedMessage().contains("\n")); + } + assertMetrics(provider); + } + + @Test + void errorAndRejectionDiagnosticsEscapeTheirContextLabels() { + service.recordWebhookFailed("sandbox", "validation\u2028error"); + service.recordWebhookRejected("sandbox", "rejected\u0085reason"); + assertEquals("Recorded webhook failure for provider: sandbox, error: validation\\u2028error", + events.list.get(0).getFormattedMessage()); + assertEquals("Recorded webhook rejection for provider: sandbox, reason: rejected\\u0085reason", + events.list.get(1).getFormattedMessage()); + assertEquals(1, registry.get("firefly.webhooks.failed").tags("provider", "sandbox", "error.type", "validation\u2028error").counter().count()); + assertEquals(1, registry.get("firefly.webhooks.rejected").tags("provider", "sandbox", "reason", "rejected\u0085reason").counter().count()); + } + + @Test + void ordinaryAndNullLabelsRetainDiagnosticTextAndLevel() { + service.recordWebhookReceived("sandbox"); + new WebhookMetricsService(null).recordWebhookFailed(null, null); + assertEquals("Recorded webhook received for provider: sandbox", events.list.get(0).getFormattedMessage()); + assertEquals("Recorded webhook failure for provider: null, error: null", events.list.get(1).getFormattedMessage()); + assertTrue(events.list.stream().allMatch(event -> event.getLevel() == Level.DEBUG)); + } + + @Test + void disabledDiagnosticsPreserveCountersTimerAndPayloadSummary() { + logger.setLevel(Level.OFF); + recordAll("sandbox"); + assertTrue(events.list.isEmpty()); + assertMetrics("sandbox"); + } + + private void recordAll(String provider) { + service.recordWebhookReceived(provider); + service.recordWebhookPublished(provider); + service.recordWebhookFailed(provider, "validation"); + service.recordWebhookRejected(provider, "rejected"); + service.recordDuplicateWebhook(provider); + service.recordProcessingTime(provider, Instant.now().minusMillis(20)); + service.recordPayloadSize(provider, 123); + } + + private void assertMetrics(String provider) { + for (String name : new String[] {"received", "published", "failed", "rejected", "duplicates"}) { + assertEquals(1, registry.get("firefly.webhooks." + name).tag("provider", provider).counter().count()); + } + assertEquals(1, registry.get("firefly.webhooks.processing.time").tag("provider", provider).timer().count()); + var payload = registry.get("firefly.webhooks.payload.size").tag("provider", provider).summary(); + assertEquals(1, payload.count()); + assertEquals(123, payload.totalAmount()); + } +} From 6f1e1f76448a429c1a3522159215c3423734d1fd Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Mon, 26 Oct 2026 10:11:08 -0700 Subject: [PATCH 26/44] Keep orchestration diagnostics on one log line --- .../OrchestrationLoggerEvents.java | 25 ++-- .../OrchestrationLoggerEventsLoggingTest.java | 137 ++++++++++++++++++ 2 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 modules/orchestration/src/test/java/org/fireflyframework/orchestration/core/observability/OrchestrationLoggerEventsLoggingTest.java diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/observability/OrchestrationLoggerEvents.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/observability/OrchestrationLoggerEvents.java index e97651a6c..180e3ff19 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/observability/OrchestrationLoggerEvents.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/observability/OrchestrationLoggerEvents.java @@ -16,6 +16,7 @@ package org.fireflyframework.orchestration.core.observability; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.orchestration.core.context.TccPhase; import org.fireflyframework.orchestration.core.model.ExecutionPattern; import lombok.extern.slf4j.Slf4j; @@ -24,50 +25,50 @@ public class OrchestrationLoggerEvents implements OrchestrationEvents { @Override public void onStart(String name, String correlationId, ExecutionPattern pattern) { - log.info("[orchestration] started name={} correlationId={} pattern={}", name, correlationId, pattern); + log.info("[orchestration] started name={} correlationId={} pattern={}", LogFields.text(name), LogFields.text(correlationId), pattern); } @Override public void onStepStarted(String name, String correlationId, String stepId) { - log.info("[orchestration] step.started name={} correlationId={} stepId={}", name, correlationId, stepId); + log.info("[orchestration] step.started name={} correlationId={} stepId={}", LogFields.text(name), LogFields.text(correlationId), LogFields.text(stepId)); } @Override public void onStepSuccess(String name, String correlationId, String stepId, int attempts, long latencyMs) { - log.info("[orchestration] step.success name={} correlationId={} stepId={} attempts={} latencyMs={}", name, correlationId, stepId, attempts, latencyMs); + log.info("[orchestration] step.success name={} correlationId={} stepId={} attempts={} latencyMs={}", LogFields.text(name), LogFields.text(correlationId), LogFields.text(stepId), attempts, latencyMs); } @Override public void onStepFailed(String name, String correlationId, String stepId, Throwable error, int attempts) { - log.warn("[orchestration] step.failed name={} correlationId={} stepId={} attempts={} error={}", name, correlationId, stepId, attempts, error.getMessage()); + log.warn("[orchestration] step.failed name={} correlationId={} stepId={} attempts={} error={}", LogFields.text(name), LogFields.text(correlationId), LogFields.text(stepId), attempts, LogFields.text(error.getMessage())); } @Override public void onCompleted(String name, String correlationId, ExecutionPattern pattern, boolean success, long durationMs) { - log.info("[orchestration] completed name={} correlationId={} pattern={} success={} durationMs={}", name, correlationId, pattern, success, durationMs); + log.info("[orchestration] completed name={} correlationId={} pattern={} success={} durationMs={}", LogFields.text(name), LogFields.text(correlationId), pattern, success, durationMs); } @Override public void onCompensationStarted(String name, String correlationId) { - log.warn("[orchestration] compensation.started name={} correlationId={}", name, correlationId); + log.warn("[orchestration] compensation.started name={} correlationId={}", LogFields.text(name), LogFields.text(correlationId)); } @Override public void onStepCompensated(String name, String correlationId, String stepId) { - log.info("[orchestration] step.compensated name={} correlationId={} stepId={}", name, correlationId, stepId); + log.info("[orchestration] step.compensated name={} correlationId={} stepId={}", LogFields.text(name), LogFields.text(correlationId), LogFields.text(stepId)); } @Override public void onStepCompensationFailed(String name, String correlationId, String stepId, Throwable error) { - log.error("[orchestration] step.compensation.failed name={} correlationId={} stepId={} error={}", name, correlationId, stepId, error.getMessage()); + log.error("[orchestration] step.compensation.failed name={} correlationId={} stepId={} error={}", LogFields.text(name), LogFields.text(correlationId), LogFields.text(stepId), LogFields.text(error.getMessage())); } @Override public void onPhaseStarted(String name, String correlationId, TccPhase phase) { - log.info("[orchestration] tcc.phase.started name={} correlationId={} phase={}", name, correlationId, phase); + log.info("[orchestration] tcc.phase.started name={} correlationId={} phase={}", LogFields.text(name), LogFields.text(correlationId), phase); } @Override public void onPhaseCompleted(String name, String correlationId, TccPhase phase, long durationMs) { - log.info("[orchestration] tcc.phase.completed name={} correlationId={} phase={} durationMs={}", name, correlationId, phase, durationMs); + log.info("[orchestration] tcc.phase.completed name={} correlationId={} phase={} durationMs={}", LogFields.text(name), LogFields.text(correlationId), phase, durationMs); } @Override public void onPhaseFailed(String name, String correlationId, TccPhase phase, Throwable error) { - log.error("[orchestration] tcc.phase.failed name={} correlationId={} phase={} error={}", name, correlationId, phase, error.getMessage()); + log.error("[orchestration] tcc.phase.failed name={} correlationId={} phase={} error={}", LogFields.text(name), LogFields.text(correlationId), phase, LogFields.text(error.getMessage())); } @Override public void onDeadLettered(String name, String correlationId, String stepId, Throwable error) { - log.error("[orchestration] dead-lettered name={} correlationId={} stepId={} error={}", name, correlationId, stepId, error.getMessage()); + log.error("[orchestration] dead-lettered name={} correlationId={} stepId={} error={}", LogFields.text(name), LogFields.text(correlationId), LogFields.text(stepId), LogFields.text(error.getMessage())); } } diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/core/observability/OrchestrationLoggerEventsLoggingTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/core/observability/OrchestrationLoggerEventsLoggingTest.java new file mode 100644 index 000000000..37ada04c5 --- /dev/null +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/core/observability/OrchestrationLoggerEventsLoggingTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fireflyframework.orchestration.core.observability; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.fireflyframework.orchestration.core.context.TccPhase; +import org.fireflyframework.orchestration.core.model.ExecutionPattern; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.slf4j.LoggerFactory; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.*; + +class OrchestrationLoggerEventsLoggingTest { + private static final String BREAKS="\r\n\u000b\f\u0085\u2028\u2029"; + private static final String VISIBLE="\\r\\n\\u000b\\f\\u0085\\u2028\\u2029"; + private final OrchestrationLoggerEvents events=new OrchestrationLoggerEvents(); + private Logger logger; + private Level previousLevel; + private boolean previousAdditive; + private ListAppender capture; + private static final class Failure extends IllegalStateException { + private int messageReads; + Failure(String message) { super(message); } + @Override public String getMessage() { messageReads++;return super.getMessage(); } + @Override public String toString() { throw new AssertionError("Throwable rendering is not a diagnostic field"); } + } + private record Sample(String name,String correlation,String step,ExecutionPattern pattern,TccPhase phase,String message,Failure failure) { + static Sample ordinary() { return new Sample("checkout","corr-1","step-1",ExecutionPattern.SAGA,TccPhase.CONFIRM,"business refusal",new Failure("business refusal")); } + static Sample text(String suffix) { return new Sample("name"+suffix,"correlation"+suffix,"step"+suffix,ExecutionPattern.SAGA,TccPhase.CONFIRM,"failure"+suffix,new Failure("failure"+suffix)); } + } + private record Callback(String name,Level level,String template,boolean usesFailure,BiConsumer invoke,Function arguments,String ordinaryMessage) { } + private static final List CALLBACKS=List.of( + new Callback("start",Level.INFO,"[orchestration] started name={} correlationId={} pattern={}",false, + (e,s)->e.onStart(s.name(),s.correlation(),s.pattern()),s->new Object[]{s.name(),s.correlation(),s.pattern()}, + "[orchestration] started name=checkout correlationId=corr-1 pattern=SAGA"), + new Callback("step started",Level.INFO,"[orchestration] step.started name={} correlationId={} stepId={}",false, + (e,s)->e.onStepStarted(s.name(),s.correlation(),s.step()),s->new Object[]{s.name(),s.correlation(),s.step()}, + "[orchestration] step.started name=checkout correlationId=corr-1 stepId=step-1"), + new Callback("step success",Level.INFO,"[orchestration] step.success name={} correlationId={} stepId={} attempts={} latencyMs={}",false, + (e,s)->e.onStepSuccess(s.name(),s.correlation(),s.step(),2,17L),s->new Object[]{s.name(),s.correlation(),s.step(),2,17L}, + "[orchestration] step.success name=checkout correlationId=corr-1 stepId=step-1 attempts=2 latencyMs=17"), + new Callback("step failed",Level.WARN,"[orchestration] step.failed name={} correlationId={} stepId={} attempts={} error={}",true, + (e,s)->e.onStepFailed(s.name(),s.correlation(),s.step(),s.failure(),2),s->new Object[]{s.name(),s.correlation(),s.step(),2,s.message()}, + "[orchestration] step.failed name=checkout correlationId=corr-1 stepId=step-1 attempts=2 error=business refusal"), + new Callback("completed",Level.INFO,"[orchestration] completed name={} correlationId={} pattern={} success={} durationMs={}",false, + (e,s)->e.onCompleted(s.name(),s.correlation(),s.pattern(),true,47L),s->new Object[]{s.name(),s.correlation(),s.pattern(),true,47L}, + "[orchestration] completed name=checkout correlationId=corr-1 pattern=SAGA success=true durationMs=47"), + new Callback("compensation started",Level.WARN,"[orchestration] compensation.started name={} correlationId={}",false, + (e,s)->e.onCompensationStarted(s.name(),s.correlation()),s->new Object[]{s.name(),s.correlation()}, + "[orchestration] compensation.started name=checkout correlationId=corr-1"), + new Callback("step compensated",Level.INFO,"[orchestration] step.compensated name={} correlationId={} stepId={}",false, + (e,s)->e.onStepCompensated(s.name(),s.correlation(),s.step()),s->new Object[]{s.name(),s.correlation(),s.step()}, + "[orchestration] step.compensated name=checkout correlationId=corr-1 stepId=step-1"), + new Callback("compensation failed",Level.ERROR,"[orchestration] step.compensation.failed name={} correlationId={} stepId={} error={}",true, + (e,s)->e.onStepCompensationFailed(s.name(),s.correlation(),s.step(),s.failure()),s->new Object[]{s.name(),s.correlation(),s.step(),s.message()}, + "[orchestration] step.compensation.failed name=checkout correlationId=corr-1 stepId=step-1 error=business refusal"), + new Callback("phase started",Level.INFO,"[orchestration] tcc.phase.started name={} correlationId={} phase={}",false, + (e,s)->e.onPhaseStarted(s.name(),s.correlation(),s.phase()),s->new Object[]{s.name(),s.correlation(),s.phase()}, + "[orchestration] tcc.phase.started name=checkout correlationId=corr-1 phase=CONFIRM"), + new Callback("phase completed",Level.INFO,"[orchestration] tcc.phase.completed name={} correlationId={} phase={} durationMs={}",false, + (e,s)->e.onPhaseCompleted(s.name(),s.correlation(),s.phase(),47L),s->new Object[]{s.name(),s.correlation(),s.phase(),47L}, + "[orchestration] tcc.phase.completed name=checkout correlationId=corr-1 phase=CONFIRM durationMs=47"), + new Callback("phase failed",Level.ERROR,"[orchestration] tcc.phase.failed name={} correlationId={} phase={} error={}",true, + (e,s)->e.onPhaseFailed(s.name(),s.correlation(),s.phase(),s.failure()),s->new Object[]{s.name(),s.correlation(),s.phase(),s.message()}, + "[orchestration] tcc.phase.failed name=checkout correlationId=corr-1 phase=CONFIRM error=business refusal"), + new Callback("dead lettered",Level.ERROR,"[orchestration] dead-lettered name={} correlationId={} stepId={} error={}",true, + (e,s)->e.onDeadLettered(s.name(),s.correlation(),s.step(),s.failure()),s->new Object[]{s.name(),s.correlation(),s.step(),s.message()}, + "[orchestration] dead-lettered name=checkout correlationId=corr-1 stepId=step-1 error=business refusal") + ); + @BeforeEach void captureEvents() { + logger=(Logger)LoggerFactory.getLogger(OrchestrationLoggerEvents.class); + previousLevel=logger.getLevel();previousAdditive=logger.isAdditive();logger.setLevel(Level.TRACE);logger.setAdditive(false); + capture=new ListAppender<>();capture.setContext(logger.getLoggerContext());capture.start();logger.addAppender(capture); + } + @AfterEach void restoreLogger() { + logger.detachAppender(capture);capture.stop();logger.setLevel(previousLevel);logger.setAdditive(previousAdditive); + } + private ILoggingEvent emit(Callback callback,Sample actual,Sample expected) { + capture.list.clear();callback.invoke().accept(events,actual);assertEquals(1,capture.list.size());var event=capture.list.getFirst(); + assertEquals(callback.level(),event.getLevel());assertEquals(callback.template(),event.getMessage()); + assertArrayEquals(callback.arguments().apply(expected),event.getArgumentArray());assertNull(event.getThrowableProxy()); + if(actual.failure()!=null)assertEquals(callback.usesFailure()?1:0,actual.failure().messageReads); + return event; + } + @TestFactory Stream eachCallbackEscapesLineSeparators() { + return CALLBACKS.stream().map(c->DynamicTest.dynamicTest(c.name(),()->{ + var event=emit(c,Sample.text(BREAKS),Sample.text(VISIBLE));String formatted=event.getFormattedMessage(); + for(char boundary:BREAKS.toCharArray())assertFalse(formatted.indexOf(boundary)>=0); + assertTrue(formatted.contains("name"+VISIBLE));assertTrue(formatted.contains("correlation"+VISIBLE)); + })); + } + @TestFactory Stream eachCallbackPreservesOrdinaryTextAndTypedArguments() { + return CALLBACKS.stream().map(c->DynamicTest.dynamicTest(c.name(),()-> + assertEquals(c.ordinaryMessage(),emit(c,Sample.ordinary(),Sample.ordinary()).getFormattedMessage()))); + } + @TestFactory Stream eachCallbackPreservesNullFields() { + return CALLBACKS.stream().map(c->DynamicTest.dynamicTest(c.name(),()->{ + var s=new Sample(null,null,null,null,null,"business refusal",new Failure("business refusal")); + assertTrue(emit(c,s,s).getFormattedMessage().contains("name=null correlationId=null")); + })); + } + @TestFactory Stream failureCallbacksPreserveNullMessages() { + return CALLBACKS.stream().filter(Callback::usesFailure).map(c->DynamicTest.dynamicTest(c.name(),()->{ + var s=new Sample("checkout","corr-1","step-1",ExecutionPattern.SAGA,TccPhase.CONFIRM,null,new Failure(null)); + assertTrue(emit(c,s,s).getFormattedMessage().endsWith("error=null")); + })); + } + @TestFactory Stream failureCallbacksPreserveNullThrowableFailure() { + return CALLBACKS.stream().filter(Callback::usesFailure).map(c->DynamicTest.dynamicTest(c.name(),()->{ + capture.list.clear();var s=new Sample("checkout","corr-1","step-1",ExecutionPattern.SAGA,TccPhase.CONFIRM,null,null); + assertThrows(NullPointerException.class,()->c.invoke().accept(events,s));assertTrue(capture.list.isEmpty()); + })); + } +} From 313c35d3d5a1724a62126d19eab3ac5151836d58 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Mon, 26 Oct 2026 12:49:08 -0700 Subject: [PATCH 27/44] Escape line separators in orchestration EDA diagnostics --- .../event/EdaOrchestrationEventPublisher.java | 9 +- ...rchestrationEventPublisherLoggingTest.java | 131 ++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 modules/orchestration/src/test/java/org/fireflyframework/orchestration/core/event/EdaOrchestrationEventPublisherLoggingTest.java diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/event/EdaOrchestrationEventPublisher.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/event/EdaOrchestrationEventPublisher.java index ccec5a387..c6c72428c 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/event/EdaOrchestrationEventPublisher.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/core/event/EdaOrchestrationEventPublisher.java @@ -18,6 +18,7 @@ import org.fireflyframework.eda.publisher.EventPublisher; import org.fireflyframework.eda.publisher.EventPublisherFactory; +import org.fireflyframework.kernel.logging.LogFields; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; @@ -86,7 +87,7 @@ public Mono publish(OrchestrationEvent event) { : defaultTopic; if (destination == null || destination.isBlank()) { log.warn("[orchestration-eda-bridge] dropping event {} (executionName={} correlationId={}) — no topic and no default-topic configured", - event.eventType(), event.executionName(), event.correlationId()); + LogFields.text(event.eventType()), LogFields.text(event.executionName()), LogFields.text(event.correlationId())); return Mono.empty(); } @@ -94,16 +95,16 @@ public Mono publish(OrchestrationEvent event) { EventPublisher publisher = publisherFactory.getDefaultPublisher(); if (publisher == null) { log.warn("[orchestration-eda-bridge] no EDA default publisher available — dropping event {} on topic {}", - event.eventType(), destination); + LogFields.text(event.eventType()), LogFields.text(destination)); return Mono.empty(); } Map headers = buildHeaders(event); return publisher.publish(event, destination, headers) - .doOnSuccess(v -> log.debug("[orchestration-eda-bridge] published {} to {}", event.eventType(), destination)) + .doOnSuccess(v -> log.debug("[orchestration-eda-bridge] published {} to {}", LogFields.text(event.eventType()), LogFields.text(destination))) .doOnError(err -> log.error("[orchestration-eda-bridge] failed to publish {} to {}: {}", - event.eventType(), destination, err.getMessage())); + LogFields.text(event.eventType()), LogFields.text(destination), LogFields.text(err.getMessage()))); } /** diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/core/event/EdaOrchestrationEventPublisherLoggingTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/core/event/EdaOrchestrationEventPublisherLoggingTest.java new file mode 100644 index 000000000..c609de714 --- /dev/null +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/core/event/EdaOrchestrationEventPublisherLoggingTest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.orchestration.core.event; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.fireflyframework.eda.annotation.PublisherType; +import org.fireflyframework.eda.publisher.EventPublisher; +import org.fireflyframework.eda.publisher.EventPublisherFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class EdaOrchestrationEventPublisherLoggingTest { + private static final String BREAKS = "\r\n\u000b\f\u0085\u2028\u2029"; + private static final String VISIBLE = "\\r\\n\\u000b\\f\\u0085\\u2028\\u2029"; + private Logger logger; + private Level level; + private boolean additive; + private ListAppender capture; + + @BeforeEach void capture() { + logger = (Logger) LoggerFactory.getLogger(EdaOrchestrationEventPublisher.class); + level = logger.getLevel(); additive = logger.isAdditive(); + logger.setLevel(Level.TRACE); logger.setAdditive(false); + capture = new ListAppender<>(); capture.setContext(logger.getLoggerContext()); + capture.start(); logger.addAppender(capture); + } + + @AfterEach void restore() { + logger.detachAppender(capture); capture.stop(); + logger.setLevel(level); logger.setAdditive(additive); + } + + private static final class RecordingPublisher implements EventPublisher { + Object event; + String destination; + Map headers; + RuntimeException failure; + int calls; + @Override public Mono publish(Object event, String destination, Map headers) { + this.event = event; this.destination = destination; this.headers = headers; calls++; + return failure == null ? Mono.empty() : Mono.error(failure); + } + @Override public boolean isAvailable() { return true; } + @Override public PublisherType getPublisherType() { return null; } + } + + private EventPublisherFactory factory(EventPublisher publisher) { + return new EventPublisherFactory(List.of(), null, null) { + @Override public EventPublisher getDefaultPublisher() { return publisher; } + }; + } + + private OrchestrationEvent event(String text, String topic) { + return new OrchestrationEvent(text, text, text, null, text, null, + Map.of("data", "unchanged"), null, topic, text, text); + } + + private ILoggingEvent logged(Level expectedLevel, Object... arguments) { + assertEquals(1, capture.list.size()); + ILoggingEvent log = capture.list.getFirst(); + assertEquals(expectedLevel, log.getLevel()); + assertArrayEquals(arguments, log.getArgumentArray()); + assertNull(log.getThrowableProxy()); + for (char c : BREAKS.toCharArray()) assertFalse(log.getFormattedMessage().indexOf(c) >= 0); + return log; + } + + private DynamicTest checked(String name, Runnable assertion) { + return DynamicTest.dynamicTest(name, () -> { capture.list.clear(); assertion.run(); }); + } + + private Stream scenarios(String actual, String expected) { + return Stream.of( + checked("missing destination", () -> { + new EdaOrchestrationEventPublisher(null, null).publish(event(actual, null)).block(); + logged(Level.WARN, expected, expected, expected); + }), + checked("missing publisher", () -> { + new EdaOrchestrationEventPublisher(factory(null), "fallback").publish(event(actual, actual)).block(); + logged(Level.WARN, expected, actual == null ? "fallback" : expected); + }), + checked("successful publication preserves raw transport", () -> { + var publisher = new RecordingPublisher(); var event = event(actual, actual); + new EdaOrchestrationEventPublisher(factory(publisher), "fallback").publish(event).block(); + assertEquals(1, publisher.calls); assertSame(event, publisher.event); + assertEquals(actual == null ? "fallback" : actual, publisher.destination); + if (actual != null) { + assertEquals(actual, publisher.headers.get("eventType")); + assertEquals(actual, publisher.headers.get("executionName")); + assertEquals(actual, publisher.headers.get("correlationId")); + assertEquals(actual, publisher.headers.get("routing_key")); + } else assertEquals(":", publisher.headers.get("routing_key")); + logged(Level.DEBUG, expected, actual == null ? "fallback" : expected); + }), + checked("failed publication preserves error", () -> { + var publisher = new RecordingPublisher(); publisher.failure = new IllegalStateException(actual); + var event = event(actual, actual); + Mono result = new EdaOrchestrationEventPublisher(factory(publisher), "fallback").publish(event); + assertSame(publisher.failure, assertThrows(IllegalStateException.class, result::block)); + assertEquals(1, publisher.calls); assertSame(event, publisher.event); + logged(Level.ERROR, expected, actual == null ? "fallback" : expected, expected); + }) + ); + } + + @TestFactory Stream escapesOnlyLogFields() { return scenarios("field" + BREAKS, "field" + VISIBLE); } + @TestFactory Stream preservesOrdinaryFields() { return scenarios("ordinary", "ordinary"); } + @TestFactory Stream preservesNullFields() { return scenarios(null, null); } + @Test void nullEventProducesNoLogOrFactoryCall() { + assertNull(new EdaOrchestrationEventPublisher(null, null).publish(null).block()); + assertTrue(capture.list.isEmpty()); + } +} From 49a28d80aae23183672a7aad6d79db3eced90cdc Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Mon, 26 Oct 2026 14:56:11 -0700 Subject: [PATCH 28/44] Escape line separators in webhook processing diagnostics --- .../impl/WebhookProcessingServiceImpl.java | 21 +-- .../WebhookProcessingServiceLoggingTest.java | 132 ++++++++++++++++++ 2 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/services/impl/WebhookProcessingServiceLoggingTest.java diff --git a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/services/impl/WebhookProcessingServiceImpl.java b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/services/impl/WebhookProcessingServiceImpl.java index 16dcb1f33..055e849f7 100644 --- a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/services/impl/WebhookProcessingServiceImpl.java +++ b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/services/impl/WebhookProcessingServiceImpl.java @@ -16,6 +16,7 @@ package org.fireflyframework.webhooks.core.services.impl; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.eda.publisher.EventPublisher; import org.fireflyframework.eda.publisher.EventPublisherFactory; import org.fireflyframework.webhooks.core.domain.events.WebhookReceivedEvent; @@ -78,7 +79,7 @@ public class WebhookProcessingServiceImpl implements WebhookProcessingService { @Override public Mono processWebhook(WebhookEventDTO eventDto) { - log.info("Processing webhook from provider: {}", eventDto.getProviderName()); + log.info("Processing webhook from provider: {}", LogFields.text(eventDto.getProviderName())); // Convert DTO to domain event WebhookReceivedEvent domainEvent = webhookEventMapper.toDomainEvent(eventDto); @@ -111,10 +112,10 @@ public Mono processWebhook(WebhookEventDTO eventDto) { ))) .doOnSuccess(response -> log.info("Successfully processed webhook {} from provider: {}", - eventDto.getEventId(), eventDto.getProviderName())) + eventDto.getEventId(), LogFields.text(eventDto.getProviderName()))) .onErrorResume(error -> { log.error("Error processing webhook from provider: {}", - eventDto.getProviderName(), error); + LogFields.text(eventDto.getProviderName()), error); return Mono.just(WebhookResponseDTO.error( eventDto.getEventId(), eventDto.getProviderName(), @@ -187,11 +188,11 @@ private Mono publishToQueue(WebhookReceivedEvent event) { // This allows end-to-end tracing from HTTP request -> Kafka -> Worker addTracingHeaders(headers, ctx); - log.debug("Publishing webhook event to destination: {} with tracing headers", destination); + log.debug("Publishing webhook event to destination: {} with tracing headers", LogFields.text(destination)); // Use batching if enabled, otherwise publish directly if (batchingEnabled && batchingService != null) { - log.debug("Adding event {} to batch for destination: {}", event.getEventId(), destination); + log.debug("Adding event {} to batch for destination: {}", event.getEventId(), LogFields.text(destination)); return batchingService.addToBatch(event, destination, headers) .doOnSuccess(v -> log.debug("Event {} added to batch successfully", event.getEventId())) .doOnError(error -> log.error("Error adding event {} to batch", event.getEventId(), error)); @@ -243,15 +244,15 @@ private void addTracingHeaders(Map headers, ContextView ctx) { // Add B3 propagation headers to Kafka message if (traceId != null) { headers.put("X-B3-TraceId", traceId); - log.debug("Propagating traceId to Kafka: {}", traceId); + log.debug("Propagating traceId to Kafka: {}", LogFields.text(traceId)); } if (spanId != null) { headers.put("X-B3-SpanId", spanId); - log.debug("Propagating spanId to Kafka: {}", spanId); + log.debug("Propagating spanId to Kafka: {}", LogFields.text(spanId)); } if (requestId != null) { headers.put("X-Request-ID", requestId); - log.debug("Propagating requestId to Kafka: {}", requestId); + log.debug("Propagating requestId to Kafka: {}", LogFields.text(requestId)); } } @@ -264,7 +265,7 @@ private void addTracingHeaders(Map headers, ContextView ctx) { private String resolveDestination(String providerName) { // If custom destination is set, use it directly if (customDestination != null && !customDestination.isEmpty()) { - log.debug("Using custom destination: {}", customDestination); + log.debug("Using custom destination: {}", LogFields.text(customDestination)); return customDestination; } @@ -287,7 +288,7 @@ private String resolveDestination(String providerName) { } String resolvedDestination = destination.toString(); - log.debug("Resolved destination for provider {}: {}", providerName, resolvedDestination); + log.debug("Resolved destination for provider {}: {}", LogFields.text(providerName), LogFields.text(resolvedDestination)); return resolvedDestination; } diff --git a/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/services/impl/WebhookProcessingServiceLoggingTest.java b/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/services/impl/WebhookProcessingServiceLoggingTest.java new file mode 100644 index 000000000..477d3ffc3 --- /dev/null +++ b/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/services/impl/WebhookProcessingServiceLoggingTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.webhooks.core.services.impl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import org.fireflyframework.eda.publisher.EventPublisher; +import org.fireflyframework.eda.publisher.EventPublisherFactory; +import org.fireflyframework.webhooks.core.domain.events.WebhookReceivedEvent; +import org.fireflyframework.webhooks.core.mappers.WebhookEventMapper; +import org.fireflyframework.webhooks.core.services.WebhookBatchingService; +import org.fireflyframework.webhooks.interfaces.dto.WebhookEventDTO; +import org.fireflyframework.webhooks.interfaces.dto.WebhookResponseDTO; +import org.junit.jupiter.api.*; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +import java.time.Instant; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WebhookProcessingServiceLoggingTest { + private final Logger logger = (Logger) LoggerFactory.getLogger(WebhookProcessingServiceImpl.class); + private final ListAppender events = new ListAppender<>(); + private Level oldLevel; + private boolean oldAdditive; + private Map oldMdc; + + @BeforeEach void capture() { + oldLevel = logger.getLevel(); oldAdditive = logger.isAdditive(); oldMdc = MDC.getCopyOfContextMap(); + logger.setLevel(Level.DEBUG); logger.setAdditive(false); events.start(); logger.addAppender(events); MDC.clear(); + } + @AfterEach void restore() { + logger.detachAppender(events); events.stop(); logger.setLevel(oldLevel); logger.setAdditive(oldAdditive); + MDC.clear(); if (oldMdc != null) MDC.setContextMap(oldMdc); + } + + @Test void providerDiagnosticsEscapeLineSeparatorsWhileTransportKeepsOriginalValues() { + String provider = "sandbox\r\nprovider\u0085\u2028\u2029"; + var f = new Fixture(provider); var response = f.process(Context.empty()); + f.assertOriginalTransport(response, provider); + assertSingleLine(); + assertEquals("Processing webhook from provider: sandbox\\r\\nprovider\\u0085\\u2028\\u2029", events.list.getFirst().getFormattedMessage()); + assertEquals(Level.INFO, events.list.getFirst().getLevel()); + assertEquals(Level.INFO, events.list.getLast().getLevel()); + } + @Test void configuredDestinationDiagnosticsDoNotRewritePublishedDestinationOrMetadata() { + var f = new Fixture("sandbox"); String destination = "owned\nqueue"; + ReflectionTestUtils.setField(f.service, "customDestination", destination); + var response = f.process(Context.empty()); + assertEquals(destination, f.destination); assertEquals(destination, response.getMetadata().get("destination")); + assertTrue(events.list.stream().anyMatch(e -> e.getFormattedMessage().equals("Using custom destination: owned\\nqueue"))); + assertSingleLine(); + } + @Test void reactiveTracingDiagnosticsEscapeLabelsWhileHeadersRemainUnchanged() { + var f = new Fixture("sandbox"); String trace = "trace\nlabel", span = "span\rlabel", request = "request\u2028label"; + f.process(Context.of("traceId", trace, "spanId", span, "requestId", request)); + assertEquals(trace, f.headers.get("X-B3-TraceId")); assertEquals(span, f.headers.get("X-B3-SpanId")); assertEquals(request, f.headers.get("X-Request-ID")); + assertSingleLine(); + assertTrue(events.list.stream().anyMatch(e -> e.getFormattedMessage().equals("Propagating traceId to Kafka: trace\\nlabel"))); + } + @Test void mdcFallbackPreservesOriginalTracingValuesAndEscapesOnlyDiagnostics() { + var f = new Fixture("sandbox"); MDC.put("traceId", "trace\nlabel"); MDC.put("spanId", "span\u0085label"); MDC.put("requestId", "request\u2029label"); + f.process(Context.empty()); assertEquals("trace\nlabel", f.headers.get("X-B3-TraceId")); assertEquals("span\u0085label", f.headers.get("X-B3-SpanId")); assertEquals("request\u2029label", f.headers.get("X-Request-ID")); assertSingleLine(); + } + @Test void errorResponseAndThrowableKeepOriginalMessageWhileProviderFieldIsEscaped() { + var f = new Fixture("sandbox\nprovider"); RuntimeException failure = new RuntimeException("sandbox\nfailure"); f.failure = failure; + var response = f.process(Context.empty()); assertEquals("ERROR", response.getStatus()); assertEquals("Error processing webhook: sandbox\nfailure", response.getMessage()); assertSame(f.dto.getPayload(), response.getReceivedPayload()); + var error = events.list.getLast(); assertEquals(Level.ERROR, error.getLevel()); assertEquals("Error processing webhook from provider: sandbox\\nprovider", error.getFormattedMessage()); assertSame(failure, ((ThrowableProxy) error.getThrowableProxy()).getThrowable()); assertSingleLine(); + } + @Test void batchingRetainsRawDestinationHeadersAndDomainEvent() { + var f = new Fixture("sandbox\nprovider"); var batching = mock(WebhookBatchingService.class); + when(batching.addToBatch(any(), anyString(), anyMap())).thenAnswer(inv -> { f.retain(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2)); return Mono.empty(); }); + ReflectionTestUtils.setField(f.service, "batchingEnabled", true); ReflectionTestUtils.setField(f.service, "batchingService", batching); + var response = f.process(Context.empty()); f.assertOriginalTransport(response, "sandbox\nprovider"); assertSingleLine(); + assertTrue(events.list.stream().anyMatch(e -> e.getFormattedMessage().contains("to batch for destination: sandbox\\nprovider"))); + } + @Test void ordinaryDiagnosticsKeepTextLevelsAndTransportValues() { + var f = new Fixture("sandbox"); var response = f.process(Context.empty()); f.assertOriginalTransport(response, "sandbox"); + assertEquals("Processing webhook from provider: sandbox", events.list.getFirst().getFormattedMessage()); + assertEquals("Successfully processed webhook " + f.dto.getEventId() + " from provider: sandbox", events.list.getLast().getFormattedMessage()); + assertTrue(events.list.stream().allMatch(e -> e.getThrowableProxy() == null)); + } + @Test void nullProviderRetainsExistingNullDiagnosticAndResponseBehavior() { + var f = new Fixture(null); var response = f.process(Context.empty()); f.assertOriginalTransport(response, null); + assertEquals("Processing webhook from provider: null", events.list.getFirst().getFormattedMessage()); assertEquals("null", f.destination); + } + @Test void disabledLoggingDoesNotChangePublisherInputsOrResponse() { + logger.setLevel(Level.OFF); var f = new Fixture("sandbox\nprovider"); var response = f.process(Context.of("traceId", "trace\nlabel")); + f.assertOriginalTransport(response, "sandbox\nprovider"); assertEquals("trace\nlabel", f.headers.get("X-B3-TraceId")); assertTrue(events.list.isEmpty()); + } + private void assertSingleLine() { + for (var event : events.list) for (String separator : List.of("\r", "\n", "\u0085", "\u2028", "\u2029")) assertFalse(event.getFormattedMessage().contains(separator)); + } + private static final class Fixture { + final WebhookEventDTO dto; + final WebhookReceivedEvent domain; + final WebhookProcessingServiceImpl service; + WebhookReceivedEvent published; + String destination; + Map headers; + RuntimeException failure; + Fixture(String provider) { + var payload = JsonNodeFactory.instance.objectNode().put("kind", "sandbox"); + dto = WebhookEventDTO.builder().eventId(UUID.randomUUID()).providerName(provider).receivedAt(Instant.parse("2026-10-26T00:00:00Z")).payload(payload).headers(Map.of("sandbox", "value")).httpMethod("POST").sourceIp("127.0.0.1").build(); + domain = WebhookReceivedEvent.builder().eventId(dto.getEventId()).providerName(provider).receivedAt(dto.getReceivedAt()).payload(payload).build(); + var mapper = mock(WebhookEventMapper.class); when(mapper.toDomainEvent(dto)).thenReturn(domain); + var publisher = mock(EventPublisher.class); var factory = mock(EventPublisherFactory.class); when(factory.getDefaultPublisher()).thenReturn(publisher); + when(publisher.publish(any(), anyString(), anyMap())).thenAnswer(inv -> { retain(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2)); return failure == null ? Mono.empty() : Mono.error(failure); }); + service = new WebhookProcessingServiceImpl(factory, mapper); ReflectionTestUtils.setField(service, "useProviderAsTopic", true); + } + void retain(WebhookReceivedEvent event, String topic, Map values) { published = event; destination = topic; headers = new HashMap<>(values); } + WebhookResponseDTO process(Context context) { return service.processWebhook(dto).contextWrite(context).block(java.time.Duration.ofSeconds(2)); } + void assertOriginalTransport(WebhookResponseDTO response, String provider) { + assertEquals("ACCEPTED", response.getStatus()); assertSame(domain, published); assertSame(dto.getPayload(), response.getReceivedPayload()); assertEquals(provider, response.getProviderName()); assertEquals(provider, headers.get("provider")); assertEquals(dto.getEventId().toString(), headers.get("eventId")); assertEquals(destination, response.getMetadata().get("destination")); + } + } +} From abc1d9cf6f2f4c9f15135f3742d0e6d627cd684a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Mon, 26 Oct 2026 16:25:53 -0700 Subject: [PATCH 29/44] Escape provider fields in webhook controller diagnostics --- .../web/controllers/WebhookController.java | 7 +- .../WebhookControllerLoggingTest.java | 122 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 modules/webhooks/fireflyframework-webhooks-web/src/test/java/org/fireflyframework/webhooks/web/controllers/WebhookControllerLoggingTest.java diff --git a/modules/webhooks/fireflyframework-webhooks-web/src/main/java/org/fireflyframework/webhooks/web/controllers/WebhookController.java b/modules/webhooks/fireflyframework-webhooks-web/src/main/java/org/fireflyframework/webhooks/web/controllers/WebhookController.java index b06bafb0a..d7b395a15 100644 --- a/modules/webhooks/fireflyframework-webhooks-web/src/main/java/org/fireflyframework/webhooks/web/controllers/WebhookController.java +++ b/modules/webhooks/fireflyframework-webhooks-web/src/main/java/org/fireflyframework/webhooks/web/controllers/WebhookController.java @@ -17,6 +17,7 @@ package org.fireflyframework.webhooks.web.controllers; import com.fasterxml.jackson.databind.JsonNode; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.webhooks.core.domain.WebhookMetadata; import org.fireflyframework.webhooks.core.services.WebhookMetadataEnrichmentService; import org.fireflyframework.webhooks.core.services.WebhookProcessingService; @@ -136,7 +137,7 @@ public Mono> receiveWebhook( Instant startTime = Instant.now(); UUID eventId = UUID.randomUUID(); - log.info("Received webhook from provider: {} eventId: {}", providerName, eventId); + log.info("Received webhook from provider: {} eventId: {}", LogFields.text(providerName), eventId); // Record metrics metricsService.recordWebhookReceived(providerName); @@ -216,12 +217,12 @@ private Mono processWebhook( return response; }) .doOnSuccess(response -> { - log.info("Webhook processed successfully: {} from provider: {}", eventId, providerName); + log.info("Webhook processed successfully: {} from provider: {}", eventId, LogFields.text(providerName)); metricsService.recordWebhookPublished(providerName); metricsService.recordProcessingTime(providerName, startTime); }) .doOnError(error -> { - log.error("Error processing webhook from provider: {}", providerName, error); + log.error("Error processing webhook from provider: {}", LogFields.text(providerName), error); String errorType = error.getClass().getSimpleName(); metricsService.recordWebhookFailed(providerName, errorType); }); diff --git a/modules/webhooks/fireflyframework-webhooks-web/src/test/java/org/fireflyframework/webhooks/web/controllers/WebhookControllerLoggingTest.java b/modules/webhooks/fireflyframework-webhooks-web/src/test/java/org/fireflyframework/webhooks/web/controllers/WebhookControllerLoggingTest.java new file mode 100644 index 000000000..ce60d41e2 --- /dev/null +++ b/modules/webhooks/fireflyframework-webhooks-web/src/test/java/org/fireflyframework/webhooks/web/controllers/WebhookControllerLoggingTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.webhooks.web.controllers; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import org.fireflyframework.webhooks.core.metrics.WebhookMetricsService; +import org.fireflyframework.webhooks.core.ratelimit.WebhookRateLimitService; +import org.fireflyframework.webhooks.core.services.WebhookMetadataEnrichmentService; +import org.fireflyframework.webhooks.core.services.WebhookProcessingService; +import org.fireflyframework.webhooks.core.validation.WebhookValidator; +import org.fireflyframework.webhooks.interfaces.dto.WebhookEventDTO; +import org.fireflyframework.webhooks.interfaces.dto.WebhookResponseDTO; +import org.junit.jupiter.api.*; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WebhookControllerLoggingTest { + private final Logger logger = (Logger) LoggerFactory.getLogger(WebhookController.class); + private final ListAppender events = new ListAppender<>(); + private Level oldLevel; + private boolean oldAdditive; + @BeforeEach void capture() { + oldLevel=logger.getLevel();oldAdditive=logger.isAdditive(); + logger.setLevel(Level.INFO);logger.setAdditive(false);events.start();logger.addAppender(events); + } + @AfterEach void restore() { + logger.detachAppender(events);events.stop();logger.setLevel(oldLevel);logger.setAdditive(oldAdditive); + } + @Test void receivedDiagnosticEscapesLineSeparatorsBeforeLocalValidation() { + String provider="sandbox\r\nprovider\u0085\u2028\u2029";var f=new Fixture(); + var rejected=new ResponseStatusException(HttpStatus.BAD_REQUEST,"sandbox validation"); + doThrow(rejected).when(f.validator).validateRequest(eq(provider),same(f.payload),same(f.request)); + assertSame(rejected,assertThrows(ResponseStatusException.class,()->f.receive(provider))); + assertEquals(1,events.list.size());assertEquals(Level.INFO,events.list.getFirst().getLevel()); + assertTrue(events.list.getFirst().getFormattedMessage().startsWith("Received webhook from provider: sandbox\\r\\nprovider\\u0085\\u2028\\u2029 eventId: ")); + assertSingleLine();verify(f.metrics).recordWebhookRejected(provider,"sandbox validation");verifyNoInteractions(f.processing); + } + @Test void successfulDiagnosticEscapesOnlyLogFieldAndPreservesControllerTransport() { + String provider="Sandbox\nProvider";var f=new Fixture();var response=f.receive(provider); + f.assertTransport(provider,response);assertSingleLine(); + assertEquals("Webhook processed successfully: "+f.event.getEventId()+" from provider: Sandbox\\nProvider",events.list.getLast().getFormattedMessage()); + assertEquals(Level.INFO,events.list.getLast().getLevel());verify(f.metrics).recordWebhookPublished(provider); + } + @Test void processingErrorKeepsOriginalThrowableAndEscapesProviderDiagnostic() { + String provider="Sandbox\rProvider";var f=new Fixture();var failure=new IllegalStateException("sandbox\nmessage");f.failure=failure; + assertSame(failure,assertThrows(IllegalStateException.class,()->f.receive(provider))); + var event=events.list.getLast();assertEquals(Level.ERROR,event.getLevel()); + assertEquals("Error processing webhook from provider: Sandbox\\rProvider",event.getFormattedMessage()); + assertSame(failure,((ThrowableProxy)event.getThrowableProxy()).getThrowable());assertSingleLine(); + assertSame(f.payload,f.event.getPayload());assertEquals(provider.toLowerCase(),f.event.getProviderName()); + verify(f.metrics).recordWebhookFailed(provider,"IllegalStateException"); + } + @Test void ordinaryDiagnosticsKeepTextLevelsAcceptedStatusAndBodyIdentity() { + var f=new Fixture();var response=f.receive("Sandbox");f.assertTransport("Sandbox",response); + assertEquals("Received webhook from provider: Sandbox eventId: "+f.event.getEventId(),events.list.getFirst().getFormattedMessage()); + assertEquals("Webhook processed successfully: "+f.event.getEventId()+" from provider: Sandbox",events.list.getLast().getFormattedMessage()); + assertTrue(events.list.stream().allMatch(e->e.getLevel()==Level.INFO&&e.getThrowableProxy()==null)); + } + @Test void validationStatusReasonAndRawProviderAreUnchanged() { + var f=new Fixture();var failure=new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE,"sandbox reason"); + doThrow(failure).when(f.validator).validateRequest(eq("sandbox"),same(f.payload),same(f.request)); + var observed=assertThrows(ResponseStatusException.class,()->f.receive("sandbox"));assertSame(failure,observed);assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE,observed.getStatusCode());assertEquals("sandbox reason",observed.getReason()); + verifyNoInteractions(f.processing);verify(f.metrics).recordWebhookRejected("sandbox","sandbox reason"); + } + @Test void localRateLimitRefusalKeepsStatusAndDoesNotInvokeValidationOrProcessing() { + var f=new Fixture();var failure=new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,"sandbox limit"); + when(f.limits.executeWithRateLimit(eq("sandbox"),any())).thenReturn(Mono.error(failure)); + assertSame(failure,assertThrows(ResponseStatusException.class,()->f.receive("sandbox")));verifyNoInteractions(f.validator,f.processing);assertEquals(1,events.list.size()); + } + @Test void disabledLoggingDoesNotChangeControllerInputsResponseOrMetrics() { + logger.setLevel(Level.OFF);var f=new Fixture();var response=f.receive("Sandbox\nProvider");f.assertTransport("Sandbox\nProvider",response);assertTrue(events.list.isEmpty());verify(f.metrics).recordWebhookPublished("Sandbox\nProvider"); + } + @Test void nullProviderKeepsExistingNullDiagnosticAndFailureBoundary() { + var f=new Fixture();assertThrows(NullPointerException.class,()->f.receive(null)); + assertTrue(events.list.getFirst().getFormattedMessage().startsWith("Received webhook from provider: null eventId: "));verifyNoInteractions(f.validator,f.processing); + } + private void assertSingleLine() { + for(var event:events.list)for(String separator:List.of("\r","\n","\u0085","\u2028","\u2029"))assertFalse(event.getFormattedMessage().contains(separator)); + } + private static final class Fixture { + final WebhookProcessingService processing=mock(WebhookProcessingService.class); + final WebhookValidator validator=mock(WebhookValidator.class); + final WebhookMetricsService metrics=mock(WebhookMetricsService.class); + final WebhookRateLimitService limits=mock(WebhookRateLimitService.class); + final WebhookMetadataEnrichmentService enrichment=mock(WebhookMetadataEnrichmentService.class); + final WebhookController controller=new WebhookController(processing,validator,metrics,limits,enrichment); + final com.fasterxml.jackson.databind.JsonNode payload=JsonNodeFactory.instance.objectNode().put("kind","sandbox"); + final MockServerHttpRequest request=MockServerHttpRequest.post("/owned-local-fixture").header("X-Sandbox","original").header("X-Forwarded-For","192.0.2.10, 192.0.2.11").build(); + final LinkedMultiValueMap query=new LinkedMultiValueMap<>(); + final WebhookResponseDTO body=WebhookResponseDTO.builder().status("ACCEPTED").message("sandbox response").metadata(new HashMap<>()).build(); + WebhookEventDTO event;RuntimeException failure; + Fixture() { + query.add("sandbox","first");query.add("sandbox","second"); + when(limits.executeWithRateLimit(any(),any())).thenAnswer(inv->inv.getArgument(1));when(limits.executeWithIpRateLimit(any(),any())).thenAnswer(inv->inv.getArgument(1)); + when(processing.processWebhook(any())).thenAnswer(inv->{event=inv.getArgument(0);return failure==null?Mono.just(body):Mono.error(failure);}); + } + org.springframework.http.ResponseEntity receive(String provider){return controller.receiveWebhook(provider,payload,request,query).block(Duration.ofSeconds(2));} + void assertTransport(String provider,org.springframework.http.ResponseEntity response) { + assertEquals(HttpStatus.ACCEPTED,response.getStatusCode());assertSame(body,response.getBody());assertSame(payload,event.getPayload());assertEquals(provider.toLowerCase(),event.getProviderName());assertEquals("original",event.getHeaders().get("X-Sandbox"));assertEquals("first",event.getQueryParams().get("sandbox"));assertEquals("192.0.2.10",event.getSourceIp());assertEquals("POST",event.getHttpMethod());assertNotNull(event.getReceivedAt());assertNotNull(body.getMetadata().get("responseTimeMs")); + verify(validator).validateRequest(eq(provider),same(payload),same(request));verify(limits).executeWithRateLimit(eq(provider),any());verify(metrics).recordWebhookReceived(provider); + } + } +} From 4ec8801f1b3128b98e05bae7eb9f54c4cd793c51 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Mon, 26 Oct 2026 18:45:21 -0700 Subject: [PATCH 30/44] Add opt-in bounded R2DBC root transactions with owned cleanup --- docs/site/modules/r2dbc.md | 12 ++ modules/r2dbc/README.md | 12 ++ .../transaction/BoundedR2dbcResources.java | 27 ++++ .../transaction/BoundedRootTransaction.java | 13 ++ .../core/transaction/CleanupFact.java | 7 + .../core/transaction/CleanupPolicy.java | 18 +++ .../OwnedLeaseConnectionFactory.java | 134 ++++++++++++++++++ .../transaction/R2dbcOperationBudget.java | 39 +++++ .../core/transaction/RootCompletion.java | 16 +++ .../transaction/RootTransactionException.java | 19 +++ .../transaction/RootTransactionScope.java | 11 ++ .../core/transaction/TransactionFacts.java | 9 ++ .../impl/DefaultBoundedRootTransaction.java | 19 +++ .../BoundedR2dbcResourcesTest.java | 74 ++++++++++ .../BoundedRootTransactionTest.java | 50 +++++++ .../BoundedTransactionBoundaryTest.java | 132 +++++++++++++++++ .../transaction/R2dbcOperationBudgetTest.java | 24 ++++ 17 files changed, 616 insertions(+) create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedR2dbcResources.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedRootTransaction.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/CleanupFact.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/CleanupPolicy.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/OwnedLeaseConnectionFactory.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/R2dbcOperationBudget.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootCompletion.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootTransactionException.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootTransactionScope.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/TransactionFacts.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/impl/DefaultBoundedRootTransaction.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedR2dbcResourcesTest.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedRootTransactionTest.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedTransactionBoundaryTest.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/R2dbcOperationBudgetTest.java diff --git a/docs/site/modules/r2dbc.md b/docs/site/modules/r2dbc.md index bcf836758..8ce78774d 100644 --- a/docs/site/modules/r2dbc.md +++ b/docs/site/modules/r2dbc.md @@ -46,6 +46,18 @@ spring: password: secret ``` +### Opt-in bounded root transactions + +The source tree provides `BoundedRootTransaction` and `impl.DefaultBoundedRootTransaction` for an application-owned R2DBC transaction with a finite operation budget. Use a source build containing these classes until the corresponding release is published. This API does not register beans or change the default transaction manager. + +Build one `OwnedLeaseConnectionFactory` around the application connection factory, then construct the actual `DatabaseClient`, customized `R2dbcEntityTemplate`, Spring Data repositories, and `R2dbcTransactionManager` with that same wrapper. `BoundedR2dbcResources.verify` checks the factory/client/template/manager identities; preserve the host converter, callbacks, and repository configuration. The helper refuses an ambient transaction rather than joining another owner's work. It owns individual acquired leases and never closes the application pool. + +Capture the monotonic anchor before obtaining an original observation. Pass its original observation time, absolute expiration, explicit maximum duration, clock, and the same monotonic supplier to `R2dbcOperationBudget.observed`. Execute with a `PROPAGATION_REQUIRED` transaction definition and return one result; other propagation modes are refused. Use `RootTransactionScope.narrow` when a later native observation or dependency imposes an earlier bound. Original and narrowed budgets must share the identical `Clock` object and monotonic supplier. Budget checks include acquisition, transaction completion, and final cleanup; an expired budget does not grant a fresh timeout. Configure rollback and close limits separately with `CleanupPolicy`. + +`RootCompletion` exposes a value only after successful bounded completion. `RootTransactionException` exposes payload-free `TransactionFacts` and `CleanupFact`: commit acknowledgment and completed transaction boundary are separate observations. Timeout, cancellation, or cleanup failure can leave the business outcome uncertain. The application must recover through its authoritative action/receipt protocol. These technical facts establish no authentication, authorization, financial finality, or proof that no business effect occurred. + +Run the module and its reactor dependencies with `./mvnw -B -ntp -pl :fireflyframework-r2dbc -am verify`. The transaction tests exercise actual Spring transaction infrastructure with controlled R2DBC connections; native PostgreSQL and application journeys require separate integration tests. + ### 2. Define an entity, a filter DTO, and a paginated endpoint ```java diff --git a/modules/r2dbc/README.md b/modules/r2dbc/README.md index 98ed28736..962139393 100644 --- a/modules/r2dbc/README.md +++ b/modules/r2dbc/README.md @@ -46,6 +46,18 @@ spring: For `LocalDateTime`, `OffsetDateTime`, and `Instant` audit fields, expose `new org.fireflyframework.core.auditing.UtcOffsetDateTimeProvider(Clock.systemUTC())` as a `DateTimeProvider` bean and select its name with `@EnableR2dbcAuditing(dateTimeProviderRef = "yourBeanName")`. The injected clock is normalized to UTC. Spring Data retains create/update, immutable entity, and auditing flag behavior. This helper is opt-in; it does not enable auditing or register a bean automatically. Legacy `Date` and `Long` audit fields are unsupported by this provider. +### Opt-in bounded root transactions + +The source tree provides `BoundedRootTransaction` and `impl.DefaultBoundedRootTransaction` for an application-owned R2DBC transaction with a finite operation budget. Use a source build containing these classes until the corresponding release is published. This API does not register beans or change the default transaction manager. + +Build one `OwnedLeaseConnectionFactory` around the application connection factory, then construct the actual `DatabaseClient`, customized `R2dbcEntityTemplate`, Spring Data repositories, and `R2dbcTransactionManager` with that same wrapper. `BoundedR2dbcResources.verify` checks the factory/client/template/manager identities; preserve the host converter, callbacks, and repository configuration. The helper refuses an ambient transaction rather than joining another owner's work. It owns individual acquired leases and never closes the application pool. + +Capture the monotonic anchor before obtaining an original observation. Pass its original observation time, absolute expiration, explicit maximum duration, clock, and the same monotonic supplier to `R2dbcOperationBudget.observed`. Execute with a `PROPAGATION_REQUIRED` transaction definition and return one result; other propagation modes are refused. Use `RootTransactionScope.narrow` when a later native observation or dependency imposes an earlier bound. Original and narrowed budgets must share the identical `Clock` object and monotonic supplier. Budget checks include acquisition, transaction completion, and final cleanup; an expired budget does not grant a fresh timeout. Configure rollback and close limits separately with `CleanupPolicy`. + +`RootCompletion` exposes a value only after successful bounded completion. `RootTransactionException` exposes payload-free `TransactionFacts` and `CleanupFact`: commit acknowledgment and completed transaction boundary are separate observations. Timeout, cancellation, or cleanup failure can leave the business outcome uncertain. The application must recover through its authoritative action/receipt protocol. These technical facts establish no authentication, authorization, financial finality, or proof that no business effect occurred. + +Run the module and its reactor dependencies with `./mvnw -B -ntp -pl :fireflyframework-r2dbc -am verify`. The transaction tests exercise actual Spring transaction infrastructure with controlled R2DBC connections; native PostgreSQL and application journeys require separate integration tests. + ### 2. Define an entity, a filter DTO, and a paginated endpoint ```java diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedR2dbcResources.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedR2dbcResources.java new file mode 100644 index 000000000..bbb51d34f --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedR2dbcResources.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +import java.util.Objects; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.r2dbc.core.DatabaseClient; +/** Verifies caller-built resources without cloning or changing the caller's client or template. */ +public final class BoundedR2dbcResources { + private final OwnedLeaseConnectionFactory factory; + private final R2dbcTransactionManager manager; + private final DatabaseClient client; + private final R2dbcEntityTemplate template; + private BoundedR2dbcResources(OwnedLeaseConnectionFactory factory,DatabaseClient client,R2dbcEntityTemplate template,R2dbcTransactionManager manager){this.factory=factory;this.client=client;this.template=template;this.manager=manager;} + public static BoundedR2dbcResources verify(OwnedLeaseConnectionFactory factory,DatabaseClient client, + R2dbcEntityTemplate template,R2dbcTransactionManager manager){ + Objects.requireNonNull(factory);Objects.requireNonNull(client);Objects.requireNonNull(template);Objects.requireNonNull(manager); + if(client.getConnectionFactory()!=factory||template.getDatabaseClient()!=client||manager.getConnectionFactory()!=factory)throw new IllegalArgumentException("One qualified factory/client/template/manager graph required"); + return new BoundedR2dbcResources(factory,client,template,manager); + } + boolean current(){return client.getConnectionFactory()==factory&&template.getDatabaseClient()==client&&manager.getConnectionFactory()==factory;} + public OwnedLeaseConnectionFactory factory(){return factory;} + public R2dbcTransactionManager manager(){return manager;} +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedRootTransaction.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedRootTransaction.java new file mode 100644 index 000000000..a8615d7ed --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedRootTransaction.java @@ -0,0 +1,13 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +import java.util.function.Function; +import org.springframework.transaction.TransactionDefinition; +import reactor.core.publisher.Mono; +public interface BoundedRootTransaction { + Mono> execute(R2dbcOperationBudget original, TransactionDefinition definition, + Function> work); +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/CleanupFact.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/CleanupFact.java new file mode 100644 index 000000000..8e5104ca6 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/CleanupFact.java @@ -0,0 +1,7 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +public enum CleanupFact { COMPLETE, INCOMPLETE, UNKNOWN } diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/CleanupPolicy.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/CleanupPolicy.java new file mode 100644 index 000000000..239f91d7b --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/CleanupPolicy.java @@ -0,0 +1,18 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +import java.time.Duration; +import java.util.Objects; +public record CleanupPolicy(Duration rollback, Duration close) { + public CleanupPolicy { + finite(rollback);finite(close); + } + private static void finite(Duration value) { + Objects.requireNonNull(value); + if(value.isZero()||value.isNegative())throw new IllegalArgumentException("Finite cleanup duration required"); + value.toNanos(); + } +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/OwnedLeaseConnectionFactory.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/OwnedLeaseConnectionFactory.java new file mode 100644 index 000000000..4afe8f1b9 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/OwnedLeaseConnectionFactory.java @@ -0,0 +1,134 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; + +import io.r2dbc.spi.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.atomic.*; +import java.util.function.Function; +import org.reactivestreams.Publisher; +import org.springframework.r2dbc.connection.DelegatingConnectionFactory; +import org.springframework.transaction.*; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.reactive.*; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.*; + +/** Stable qualified identity with exact invocation-created lease ownership; never closes a pool. */ +public final class OwnedLeaseConnectionFactory extends DelegatingConnectionFactory { + private static final Object INVOCATION=new Object(); + public OwnedLeaseConnectionFactory(ConnectionFactory delegate){super(Objects.requireNonNull(delegate));} + @Override public Mono create(){return Mono.deferContextual(context->{ + var value=context.getOrDefault(INVOCATION,null); + if(!(value instanceof Invocation scope)||scope.factory!=this)return Mono.error(new IllegalStateException("Exact bounded invocation required")); + scope.remaining();scope.stage.set(RootTransactionException.Stage.ACQUIRE);scope.nativeAttempted.set(true); + return Mono.from(super.create()).map(scope::own); + });} + + /** Execution bridge used by the opt-in service; the verified graph is required on every subscription. */ + public Mono> executeBounded(BoundedR2dbcResources resources,R2dbcOperationBudget original, + TransactionDefinition definition,CleanupPolicy cleanup,Function> work){ + Objects.requireNonNull(resources);Objects.requireNonNull(original);Objects.requireNonNull(definition);Objects.requireNonNull(cleanup);Objects.requireNonNull(work); + return Mono.deferContextual(context->{ + if(resources.factory()!=this||!resources.current()||context.hasKey(INVOCATION)||definition.getPropagationBehavior()!=TransactionDefinition.PROPAGATION_REQUIRED)return Mono.error(gateFailure()); + return TransactionSynchronizationManager.forCurrentTransaction().hasElement().onErrorResume(NoTransactionException.class,e->Mono.just(false)).flatMap(active->{ + if(active)return Mono.error(gateFailure()); + final Invocation scope; + try {original.remaining();scope=new Invocation(this,resources,original,cleanup);}catch(RuntimeException expired){return Mono.error(gateFailure());} + Mono bounded=Mono.usingWhen(Mono.just(scope),owned->{ + Mono transaction=TransactionalOperator.create(owned.manager(),new DefaultTransactionDefinition(definition)) + .execute(status->TransactionSynchronizationManager.forCurrentTransaction().flatMap(sync->{ + sync.registerSynchronization(new TransactionSynchronization(){ + @Override public Mono afterCommit(){return Mono.fromRunnable(()->owned.commitAcknowledged.set(true));} + @Override public Mono afterCompletion(int state){return Mono.fromRunnable(()->{if(state==STATUS_ROLLED_BACK)owned.rollbackAcknowledged.set(true);});} + }); + owned.stage.set(RootTransactionException.Stage.WORK); + return Mono.defer(()->work.apply(owned)).switchIfEmpty(Mono.error(new IllegalStateException("One bounded root result required"))); + }).flux()).collectList().doOnSuccess(values->owned.boundaryCompleted.set(true)).flatMap(values-> + values.size()==1?Mono.just(values.getFirst()):Mono.error(new IllegalStateException("One bounded root result required"))); + return transaction.timeout(owned.deadline()).doOnError(error->owned.captureFailure()); + },Invocation::cleanup,(owned,error)->owned.cleanup(),Invocation::cleanup); + return bounded.flatMap(value->{ + scope.stage.set(RootTransactionException.Stage.FINAL_BUDGET); + scope.remaining(); + return Mono.just(new RootCompletion<>(value,scope.facts())); + }).onErrorMap(error->scope.failure()).contextWrite(c->c.put(INVOCATION,scope)); + }); + }); + } + private static RootTransactionException gateFailure(){return new RootTransactionException(new TransactionFacts(false,false,false,false),CleanupFact.COMPLETE,RootTransactionException.Stage.GATE);} + + private static final class Invocation implements RootTransactionScope { + final OwnedLeaseConnectionFactory factory; + final BoundedR2dbcResources resources; + final R2dbcOperationBudget original; + final AtomicReference cap; + final CleanupPolicy policy; + final List leases=Collections.synchronizedList(new ArrayList<>()); + final AtomicBoolean nativeAttempted=new AtomicBoolean(),commitAcknowledged=new AtomicBoolean(),boundaryCompleted=new AtomicBoolean(),rollbackAcknowledged=new AtomicBoolean(); + final AtomicReference stage=new AtomicReference<>(RootTransactionException.Stage.GATE),failureStage=new AtomicReference<>(); + final AtomicReference transaction=new AtomicReference<>(); + final AtomicReference> rollback=new AtomicReference<>(); + final AtomicBoolean incompleteCleanup=new AtomicBoolean(); + final AtomicReference cleanupFact=new AtomicReference<>(CleanupFact.UNKNOWN); + final Sinks.Many narrowed=Sinks.many().replay().latest(); + reactor.util.context.Context transactionContext; + Invocation(OwnedLeaseConnectionFactory factory,BoundedR2dbcResources resources,R2dbcOperationBudget original,CleanupPolicy policy){this.factory=factory;this.resources=resources;this.original=original;this.cap=new AtomicReference<>(original);this.policy=policy;} + @Override public Duration remaining(){var a=original.remaining();var b=cap.get().remaining();return a.compareTo(b)<0?a:b;} + @Override public void narrow(R2dbcOperationBudget observedCap){cap.updateAndGet(current->current.intersect(observedCap));remaining();if(narrowed.tryEmitNext(true).isFailure())throw new IllegalStateException("Bounded deadline observation unavailable");} + Mono deadline(){return narrowed.asFlux().startWith(false).switchMap(signal->Mono.defer(()->Mono.delay(remaining()))).next();} + Connection own(Connection raw){var lease=new Lease(raw,this);leases.add(lease);return lease;} + void captureFailure(){failureStage.compareAndSet(null,stage.get());} + TransactionFacts facts(){return new TransactionFacts(nativeAttempted.get(),commitAcknowledged.get(),boundaryCompleted.get(),rollbackAcknowledged.get());} + RootTransactionException failure(){return new RootTransactionException(facts(),cleanupFact.get(),Optional.ofNullable(failureStage.get()).orElse(stage.get()));} + ReactiveTransactionManager manager(){return new ReactiveTransactionManager(){ + @Override public Mono getReactiveTransaction(TransactionDefinition d){return Mono.deferContextual(context->{ + if(!resources.current())return Mono.error(gateFailure());transactionContext=reactor.util.context.Context.of(context);var limit=remaining();return resources.manager().getReactiveTransaction(d).timeout(limit); + }).doOnNext(transaction::set);} + @Override public Mono commit(ReactiveTransaction status){return Mono.defer(()->{stage.set(RootTransactionException.Stage.COMMIT);if(!resources.current())return Mono.error(failure());var limit=remaining();return resources.manager().commit(status).timeout(limit);});} + @Override public Mono rollback(ReactiveTransaction status){return rollbackOnce(status);} + };} + Mono rollbackOnce(ReactiveTransaction status){ + Mono existing=rollback.get();if(existing!=null)return existing; + Mono action=Mono.defer(()->{stage.set(RootTransactionException.Stage.ROLLBACK);if(!resources.current())return Mono.error(failure());return status.isCompleted()?Mono.empty():resources.manager().rollback(status);}) + .contextWrite(c->transactionContext==null?c:c.putAll(transactionContext)).timeout(policy.rollback()).doOnError(error->incompleteCleanup.set(true)).cache(); + return rollback.compareAndSet(null,action)?action:rollback.get(); + } + Mono cleanup(){return Mono.defer(()->{ + var incomplete=incompleteCleanup;var status=transaction.get(); + Mono rolled=status!=null&&!status.isCompleted()&&!commitAcknowledged.get()?rollbackOnce(status):Mono.empty(); + return rolled.onErrorResume(error->{incomplete.set(true);return Mono.empty();}).then(Mono.defer(()->{ + stage.set(RootTransactionException.Stage.CLOSE);List owned;synchronized(leases){owned=List.copyOf(leases);} + return Mono.whenDelayError(owned.stream().map(Lease::close).toList()).timeout(policy.close()); + })).doOnSuccess(ignored->cleanupFact.set(incomplete.get()?CleanupFact.INCOMPLETE:CleanupFact.COMPLETE)) + .onErrorResume(error->{cleanupFact.set(CleanupFact.INCOMPLETE);captureFailure();return Mono.error(failure());}) + .then(Mono.defer(()->incomplete.get()?Mono.error(failure()):Mono.empty())); + });} + } + private static final class Lease implements Connection { + final Connection raw;final Invocation scope;final Mono close; + Lease(Connection raw,Invocation scope){this.raw=Objects.requireNonNull(raw);this.scope=scope;close=Mono.defer(()->Mono.from(raw.close())).timeout(scope.policy.close()).cache();} + public Publisher beginTransaction(){return Mono.defer(()->{scope.stage.set(RootTransactionException.Stage.BEGIN);var limit=scope.remaining();return Mono.from(raw.beginTransaction()).timeout(limit);});} + public Publisher beginTransaction(io.r2dbc.spi.TransactionDefinition d){return Mono.defer(()->{scope.stage.set(RootTransactionException.Stage.BEGIN);var limit=scope.remaining();return Mono.from(raw.beginTransaction(d)).timeout(limit);});} + public Publisher commitTransaction(){return Mono.defer(()->{scope.stage.set(RootTransactionException.Stage.COMMIT);var limit=scope.remaining();return Mono.from(raw.commitTransaction()).timeout(limit);});} + public Publisher rollbackTransaction(){return Mono.defer(()->Mono.from(raw.rollbackTransaction())).timeout(scope.policy.rollback());} + public Mono close(){return close;} + public Batch createBatch(){return raw.createBatch();} + public Publisher createSavepoint(String name){return raw.createSavepoint(name);} + public Statement createStatement(String sql){return raw.createStatement(sql);} + public boolean isAutoCommit(){return raw.isAutoCommit();} + public ConnectionMetadata getMetadata(){return raw.getMetadata();} + public IsolationLevel getTransactionIsolationLevel(){return raw.getTransactionIsolationLevel();} + public Publisher releaseSavepoint(String name){return raw.releaseSavepoint(name);} + public Publisher rollbackTransactionToSavepoint(String name){return raw.rollbackTransactionToSavepoint(name);} + public Publisher setAutoCommit(boolean value){return raw.setAutoCommit(value);} + public Publisher setLockWaitTimeout(Duration value){return raw.setLockWaitTimeout(value);} + public Publisher setStatementTimeout(Duration value){return raw.setStatementTimeout(value);} + public Publisher setTransactionIsolationLevel(IsolationLevel value){return raw.setTransactionIsolationLevel(value);} + public Publisher validate(ValidationDepth depth){return raw.validate(depth);} + } +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/R2dbcOperationBudget.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/R2dbcOperationBudget.java new file mode 100644 index 000000000..f298cd22a --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/R2dbcOperationBudget.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +import java.time.*; +import java.util.Objects; +import java.util.function.LongSupplier; +/** Original technical deadline; it conveys no authentication or business authority. */ +public final class R2dbcOperationBudget { + private final Instant observedAt,until; + private final long beforeNanos,budgetNanos; + private final Clock clock; + private final LongSupplier nanos; + private R2dbcOperationBudget(Instant observedAt,Instant until,long beforeNanos,long budgetNanos,Clock clock,LongSupplier nanos){ + this.observedAt=observedAt;this.until=until;this.beforeNanos=beforeNanos;this.budgetNanos=budgetNanos;this.clock=clock;this.nanos=nanos; + } + public static R2dbcOperationBudget observed(Instant observedAt,Instant expiresAt,long beforeObservationNanos, + Duration explicitMaximum,Clock clock,LongSupplier monotonicNanos){ + Objects.requireNonNull(observedAt);Objects.requireNonNull(expiresAt);Objects.requireNonNull(explicitMaximum);Objects.requireNonNull(clock);Objects.requireNonNull(monotonicNanos); + var duration=Duration.between(observedAt,expiresAt); + if(duration.isNegative()||duration.isZero()||explicitMaximum.isNegative()||explicitMaximum.isZero()||duration.compareTo(explicitMaximum)>0)throw new IllegalArgumentException("Finite original operation budget required"); + explicitMaximum.toNanos();return new R2dbcOperationBudget(observedAt,expiresAt,beforeObservationNanos,duration.toNanos(),clock,monotonicNanos); + } + public Duration remaining(){ + long elapsed=nanos.getAsLong()-beforeNanos;var now=clock.instant(); + if(elapsed<0||elapsed>=budgetNanos||now.isBefore(observedAt)||!now.isBefore(until))throw new IllegalStateException("Original operation budget exhausted"); + var absolute=Duration.between(now,until);var monotonic=Duration.ofNanos(budgetNanos-elapsed);return absolute.compareTo(monotonic)<0?absolute:monotonic; + } + R2dbcOperationBudget intersect(R2dbcOperationBudget other){ + Objects.requireNonNull(other); + if(clock!=other.clock||nanos!=other.nanos)throw new IllegalArgumentException("Compatible original time sources required"); + long before=nanos.getAsLong();var a=remaining();var b=other.remaining();var remaining=a.compareTo(b)<0?a:b;var absolute=until.isBefore(other.until)?until:other.until; + // The earlier of both original limits is retained; this observation cannot grant more time. + return new R2dbcOperationBudget(clock.instant(),absolute,before,remaining.toNanos(),clock,nanos); + } + @Override public String toString(){return "R2dbcOperationBudget";} +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootCompletion.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootCompletion.java new file mode 100644 index 000000000..cc84221db --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootCompletion.java @@ -0,0 +1,16 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +import java.util.Objects; +public final class RootCompletion { + private final T value; + private final TransactionFacts facts; + public RootCompletion(T value, TransactionFacts facts) {this.value=Objects.requireNonNull(value);this.facts=Objects.requireNonNull(facts);} + public T value(){return value;} + public TransactionFacts transactionFacts(){return facts;} + public CleanupFact cleanupFact(){return CleanupFact.COMPLETE;} + @Override public String toString(){return "RootCompletion";} +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootTransactionException.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootTransactionException.java new file mode 100644 index 000000000..1b0d5f4a0 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootTransactionException.java @@ -0,0 +1,19 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +import java.util.Objects; +public final class RootTransactionException extends IllegalStateException { + public enum Stage { GATE, ACQUIRE, BEGIN, WORK, COMMIT, ROLLBACK, CLOSE, FINAL_BUDGET } + private final TransactionFacts facts; + private final CleanupFact cleanup; + private final Stage stage; + public RootTransactionException(TransactionFacts facts,CleanupFact cleanup,Stage stage){ + super("BOUNDED_ROOT_TRANSACTION_UNAVAILABLE");this.facts=Objects.requireNonNull(facts);this.cleanup=Objects.requireNonNull(cleanup);this.stage=Objects.requireNonNull(stage); + } + public TransactionFacts transactionFacts(){return facts;} + public CleanupFact cleanupFact(){return cleanup;} + public Stage stage(){return stage;} +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootTransactionScope.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootTransactionScope.java new file mode 100644 index 000000000..f52306a69 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/RootTransactionScope.java @@ -0,0 +1,11 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +import java.time.Duration; +public interface RootTransactionScope { + Duration remaining(); + void narrow(R2dbcOperationBudget observedCap); +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/TransactionFacts.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/TransactionFacts.java new file mode 100644 index 000000000..a6afbdda7 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/TransactionFacts.java @@ -0,0 +1,9 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; +/** Technical observations, never business no-effect or admission evidence. */ +public record TransactionFacts(boolean nativeAttempted, boolean commitAcknowledged, + boolean boundaryCompleted, boolean rollbackAcknowledged) {} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/impl/DefaultBoundedRootTransaction.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/impl/DefaultBoundedRootTransaction.java new file mode 100644 index 000000000..4894b92ed --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/impl/DefaultBoundedRootTransaction.java @@ -0,0 +1,19 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction.impl; +import java.util.Objects; +import java.util.function.Function; +import org.fireflyframework.core.transaction.*; +import org.springframework.transaction.TransactionDefinition; +import reactor.core.publisher.Mono; +/** Explicit opt-in service; existing transaction auto-configuration is unchanged. */ +public final class DefaultBoundedRootTransaction implements BoundedRootTransaction { + private final BoundedR2dbcResources resources; + private final CleanupPolicy cleanup; + public DefaultBoundedRootTransaction(BoundedR2dbcResources resources,CleanupPolicy cleanup){this.resources=Objects.requireNonNull(resources);this.cleanup=Objects.requireNonNull(cleanup);} + @Override public Mono> execute(R2dbcOperationBudget original,TransactionDefinition definition, + Function> work){return resources.factory().executeBounded(resources,original,definition,cleanup,work);} +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedR2dbcResourcesTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedR2dbcResourcesTest.java new file mode 100644 index 000000000..eb0f01424 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedR2dbcResourcesTest.java @@ -0,0 +1,74 @@ +package org.fireflyframework.core.transaction; + +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.fireflyframework.core.transaction.impl.DefaultBoundedRootTransaction; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.data.annotation.*; +import org.springframework.data.auditing.ReactiveIsNewAwareAuditingHandler; +import org.springframework.data.mapping.context.PersistentEntities; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.mapping.event.BeforeConvertCallback; +import org.springframework.data.r2dbc.mapping.event.ReactiveAuditingEntityCallback; +import org.springframework.data.r2dbc.repository.support.R2dbcRepositoryFactory; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import static org.junit.jupiter.api.Assertions.*; + +class BoundedR2dbcResourcesTest { + record AuditProbe(@Id UUID id,@CreatedDate Instant createdAt,@CreatedBy String createdBy) {} + interface Probes extends ReactiveCrudRepository {} + static final Instant RECORDED=Instant.parse("2026-10-26T00:00:00Z"); + static final class Probe implements BeforeConvertCallback,Ordered { + int calls;final IllegalStateException stop=new IllegalStateException("Source callback before acquisition"); + public int getOrder(){return 200;} + public Mono onBeforeConvert(AuditProbe value,SqlIdentifier table){assertEquals(RECORDED,value.createdAt());assertEquals("source-auditor",value.createdBy());calls++;return Mono.error(stop);} + } + @Test void callerManagedTemplatePreservesStrategyConverterAuditingAndRepositoryCallbacks(){ + var driver=new BoundedTransactionBoundaryTest.Driver();var original=new R2dbcEntityTemplate(driver);var owned=new OwnedLeaseConnectionFactory(driver);var client=DatabaseClient.create(owned);var template=new R2dbcEntityTemplate(client,original.getDataAccessStrategy());var probe=new Probe(); + var handler=new ReactiveIsNewAwareAuditingHandler(PersistentEntities.of(original.getConverter().getMappingContext()));handler.setAuditorAware(()->Mono.just("source-auditor"));handler.setDateTimeProvider(()->Optional.of(RECORDED)); + try(var context=new AnnotationConfigApplicationContext()){ + context.registerBean("callerTemplate",R2dbcEntityTemplate.class,()->template);context.registerBean("auditing",ReactiveAuditingEntityCallback.class,()->new ReactiveAuditingEntityCallback(()->handler));context.registerBean("probe",Probe.class,()->probe);context.refresh(); + assertSame(original.getConverter(),template.getConverter());assertSame(original.getDataAccessStrategy(),template.getDataAccessStrategy()); + var manager=new R2dbcTransactionManager(owned);var resources=BoundedR2dbcResources.verify(owned,client,template,manager);assertSame(owned,resources.factory());assertSame(owned,manager.getConnectionFactory());assertSame(client,template.getDatabaseClient()); + var factory=new R2dbcRepositoryFactory(template);factory.setBeanFactory(context);var repository=factory.getRepository(Probes.class); + for(var result:List.of(template.insert(new AuditProbe(null,null,null)),repository.save(new AuditProbe(null,null,null))))StepVerifier.create(result).expectErrorMatches(e->e==probe.stop).verify(Duration.ofSeconds(1)); + assertEquals(2,probe.calls);assertEquals(0,driver.creates); + } + } + @Test void actualCallerExecuteFunctionAndStatementFilterArePreserved(){ + var calls=new AtomicInteger();var filters=new AtomicInteger();var fixture=new java.util.concurrent.atomic.AtomicReference(); + StepVerifier.withVirtualTime(()->{ + var f=new BoundedTransactionBoundaryTest.Fixture();fixture.set(f); + var client=DatabaseClient.builder().connectionFactory(f.factory).executeFunction(statement->{calls.incrementAndGet();return statement.execute();}).build();var template=new R2dbcEntityTemplate(client,f.template.getDataAccessStrategy()); + var service=new DefaultBoundedRootTransaction(BoundedR2dbcResources.verify(f.factory,client,template,f.manager),new CleanupPolicy(Duration.ofSeconds(2),Duration.ofSeconds(2))); + return service.execute(f.original,new DefaultTransactionDefinition(),scope->client.sql("source statement").filter((statement,next)->{filters.incrementAndGet();return next.execute(statement);}).fetch().rowsUpdated()); + }).assertNext(value->assertEquals(1L,value.value())).verifyComplete();assertEquals(1,calls.get());assertEquals(1,filters.get());assertEquals(1,fixture.get().driver.creates);assertEquals(1,fixture.get().driver.closes); + } + @Test void mismatchedFactoryClientTemplateAndManagerRefuseBeforeAcquisition(){ + var own=new BoundedTransactionBoundaryTest.Driver();var foreign=new BoundedTransactionBoundaryTest.Driver();var a=new OwnedLeaseConnectionFactory(own);var b=new OwnedLeaseConnectionFactory(foreign);var client=DatabaseClient.create(a);var template=new R2dbcEntityTemplate(client,org.springframework.data.r2dbc.dialect.PostgresDialect.INSTANCE); + assertThrows(IllegalArgumentException.class,()->BoundedR2dbcResources.verify(a,client,template,new R2dbcTransactionManager(b))); + var other=DatabaseClient.create(a);assertThrows(IllegalArgumentException.class,()->BoundedR2dbcResources.verify(a,other,template,new R2dbcTransactionManager(a))); + assertThrows(IllegalArgumentException.class,()->BoundedR2dbcResources.verify(b,client,template,new R2dbcTransactionManager(b)));assertEquals(0,own.creates);assertEquals(0,foreign.creates); + } + @Test void managerFactoryChangedAfterBindingMustRefuseBeforeAnyNewAcquisition(){ + var own=new BoundedTransactionBoundaryTest.Driver();var foreign=new BoundedTransactionBoundaryTest.Driver();var factory=new OwnedLeaseConnectionFactory(own);var client=DatabaseClient.create(factory);var template=new R2dbcEntityTemplate(client,org.springframework.data.r2dbc.dialect.PostgresDialect.INSTANCE);var manager=new R2dbcTransactionManager(factory); + var resources=BoundedR2dbcResources.verify(factory,client,template,manager);manager.setConnectionFactory(foreign); + var service=new DefaultBoundedRootTransaction(resources,new CleanupPolicy(Duration.ofSeconds(2),Duration.ofSeconds(2)));var now=Instant.now();var budget=R2dbcOperationBudget.observed(now,now.plusSeconds(1),System.nanoTime(),Duration.ofSeconds(1),Clock.systemUTC(),System::nanoTime); + StepVerifier.create(service.execute(budget,new DefaultTransactionDefinition(),scope->Mono.just("protected-value"))).expectError(RootTransactionException.class).verify(Duration.ofSeconds(2));assertEquals(0,own.creates);assertEquals(0,foreign.creates);assertEquals(0,foreign.closes); + } + @Test void unusedApiHasNoAutomaticBeansWrappingOrAcquisition(){ + var driver=new BoundedTransactionBoundaryTest.Driver();var client=DatabaseClient.create(driver); + try(var context=new AnnotationConfigApplicationContext()){ + context.registerBean("callerClient",DatabaseClient.class,()->client);context.refresh();assertTrue(context.getBeansOfType(BoundedRootTransaction.class).isEmpty());assertTrue(context.getBeansOfType(OwnedLeaseConnectionFactory.class).isEmpty());assertSame(driver,client.getConnectionFactory());assertEquals(0,driver.creates); + } + } +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedRootTransactionTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedRootTransactionTest.java new file mode 100644 index 000000000..15ee04795 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedRootTransactionTest.java @@ -0,0 +1,50 @@ +package org.fireflyframework.core.transaction; + +import io.r2dbc.spi.*; +import java.lang.reflect.*; +import java.time.*; +import java.util.function.*; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.transaction.reactive.TransactionalOperator; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import static org.junit.jupiter.api.Assertions.*; + +class BoundedRootTransactionTest { + static final class Driver implements ConnectionFactory { + int creates,begins,beginCancelled,commits,closes; + final Connection connection=(Connection)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{Connection.class},(p,m,a)->switch(m.getName()) { + case "isAutoCommit" -> true; + case "getTransactionIsolationLevel" -> IsolationLevel.READ_COMMITTED; + case "beginTransaction" -> Mono.defer(()->{begins++;return Mono.never().doOnCancel(()->beginCancelled++);}); + case "commitTransaction" -> Mono.fromRunnable(()->commits++); + case "close" -> Mono.fromRunnable(()->closes++); + case "hashCode" -> System.identityHashCode(p); + case "equals" -> p==a[0]; + case "toString" -> "OwnedNetworklessLease"; + default -> Mono.empty(); + }); + public Publisher create(){return Mono.defer(()->{creates++;return Mono.just(connection);});} + public ConnectionFactoryMetadata getMetadata(){return ()->"PostgreSQL";} + } + @Test void boundedRootBeginCancellationClosesExactlyItsCreatedLease() { + var d=new Driver();StepVerifier.withVirtualTime(()->proposedOrOriginal(d)) + .thenAwait(Duration.ofSeconds(8)).expectError().verify(Duration.ofSeconds(1)); + assertEquals(1,d.creates);assertEquals(1,d.begins);assertEquals(1,d.beginCancelled);assertEquals(1,d.closes);assertEquals(0,d.commits); + } + @Test void originalSpringOuterTimeoutCharacterizationLeavesBeginLeaseUnclosed() { + var d=new Driver();StepVerifier.withVirtualTime(()->TransactionalOperator.create(new R2dbcTransactionManager(d)).transactional(Mono.just("source-value")).timeout(Duration.ofSeconds(5))) + .thenAwait(Duration.ofSeconds(6)).expectError(java.util.concurrent.TimeoutException.class).verify(Duration.ofSeconds(1)); + assertEquals(1,d.beginCancelled);assertEquals(0,d.closes);assertEquals(0,d.commits); + } + static Mono proposedOrOriginal(Driver d) { + var factory=new OwnedLeaseConnectionFactory(d);var client=org.springframework.r2dbc.core.DatabaseClient.create(factory); + var template=new org.springframework.data.r2dbc.core.R2dbcEntityTemplate(client,org.springframework.data.r2dbc.dialect.PostgresDialect.INSTANCE); + var resources=BoundedR2dbcResources.verify(factory,client,template,new R2dbcTransactionManager(factory)); + var now=Instant.now();var budget=R2dbcOperationBudget.observed(now,now.plusSeconds(5),System.nanoTime(),Duration.ofSeconds(5),Clock.systemUTC(),System::nanoTime); + return new org.fireflyframework.core.transaction.impl.DefaultBoundedRootTransaction(resources,new CleanupPolicy(Duration.ofSeconds(2),Duration.ofSeconds(2))) + .execute(budget,new org.springframework.transaction.support.DefaultTransactionDefinition(),scope->Mono.just("source-value")); + } +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedTransactionBoundaryTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedTransactionBoundaryTest.java new file mode 100644 index 000000000..ec4f42a0c --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedTransactionBoundaryTest.java @@ -0,0 +1,132 @@ +package org.fireflyframework.core.transaction; + +import io.r2dbc.spi.*; +import java.lang.reflect.Proxy; +import java.time.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.*; +import java.util.function.*; +import org.fireflyframework.core.transaction.impl.DefaultBoundedRootTransaction; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.dialect.PostgresDialect; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.transaction.reactive.*; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.test.scheduler.VirtualTimeScheduler; +import static org.junit.jupiter.api.Assertions.*; + +class BoundedTransactionBoundaryTest { + static final class Time extends Clock { + final VirtualTimeScheduler scheduler=VirtualTimeScheduler.getOrSet(); + final LongSupplier nanos=()->scheduler.now(TimeUnit.NANOSECONDS); + final AtomicLong wallOffset=new AtomicLong(); + public ZoneId getZone(){return ZoneOffset.UTC;} + public Clock withZone(ZoneId zone){if(!zone.equals(ZoneOffset.UTC))throw new IllegalArgumentException();return this;} + public Instant instant(){return Instant.parse("2026-10-26T00:00:00Z").plusNanos(nanos.getAsLong()).plusMillis(wallOffset.get());} + R2dbcOperationBudget budget(long millis){var now=instant();return R2dbcOperationBudget.observed(now,now.plusMillis(millis),nanos.getAsLong(),Duration.ofMillis(millis),this,nanos);} + } + static final class Driver implements ConnectionFactory { + int creates,begins,commits,rollbacks,closes,acquireCancelled,beginCancelled,commitCancelled,rollbackCancelled,closeCancelled; + boolean stallAcquire,stallBegin,stallCommit,stallRollback,stallClose,failRollback; + Runnable afterClose=()->{}; + final Connection connection=(Connection)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{Connection.class},(p,m,a)->switch(m.getName()){ + case "isAutoCommit" -> true; + case "getTransactionIsolationLevel" -> IsolationLevel.READ_COMMITTED; + case "beginTransaction" -> Mono.defer(()->{begins++;return stallBegin?Mono.never().doOnCancel(()->beginCancelled++):Mono.empty();}); + case "commitTransaction" -> Mono.defer(()->{commits++;return stallCommit?Mono.never().doOnCancel(()->commitCancelled++):Mono.empty();}); + case "rollbackTransaction" -> Mono.defer(()->{rollbacks++;return failRollback?Mono.error(new IllegalStateException("source rollback unavailable")):stallRollback?Mono.never().doOnCancel(()->rollbackCancelled++):Mono.empty();}); + case "close" -> Mono.defer(()->{closes++;if(stallClose)return Mono.never().doOnCancel(()->closeCancelled++);afterClose.run();return Mono.empty();}); + case "createStatement" -> statement(); + case "hashCode" -> System.identityHashCode(p); + case "equals" -> p==a[0]; + case "toString" -> "OwnedNetworklessLease"; + default -> Mono.empty(); + }); + Statement statement(){return (Statement)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{Statement.class},(p,m,a)->m.getName().equals("execute")?Mono.just((Result)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{Result.class},(v,n,b)->n.getName().equals("getRowsUpdated")?Mono.just(1L):n.getName().equals("filter")?v:Mono.empty())):p);} + public Publisher create(){return Mono.defer(()->{creates++;return stallAcquire?Mono.never().doOnCancel(()->acquireCancelled++):Mono.just(connection);});} + public ConnectionFactoryMetadata getMetadata(){return ()->"PostgreSQL";} + } + static final class Fixture { + final Time time=new Time();final Driver driver=new Driver(); + final OwnedLeaseConnectionFactory factory=new OwnedLeaseConnectionFactory(driver); + final DatabaseClient client=DatabaseClient.create(factory); + final R2dbcEntityTemplate template=new R2dbcEntityTemplate(client,PostgresDialect.INSTANCE); + final R2dbcTransactionManager manager=new R2dbcTransactionManager(factory); + final BoundedRootTransaction service=new DefaultBoundedRootTransaction(BoundedR2dbcResources.verify(factory,client,template,manager),new CleanupPolicy(Duration.ofSeconds(2),Duration.ofSeconds(2))); + final R2dbcOperationBudget original=time.budget(5000); + Mono> execute(Function> work){return service.execute(original,new DefaultTransactionDefinition(),work);} + } + record Failed(Fixture fixture,RootTransactionException error) {} + static Failed fails(Consumer configure,BiFunction> work,long seconds){ + var fixture=new AtomicReference();var error=new AtomicReference(); + StepVerifier.withVirtualTime(()->{var f=new Fixture();fixture.set(f);configure.accept(f);return f.execute(scope->work.apply(f,scope));}) + .thenAwait(Duration.ofSeconds(seconds)).expectErrorSatisfies(e->{assertInstanceOf(RootTransactionException.class,e);error.set((RootTransactionException)e);assertFalse(e.toString().contains("protected-value"));assertNull(e.getCause());}).verify(Duration.ofSeconds(1)); + return new Failed(fixture.get(),error.get()); + } + @Test void acquisitionTimeoutHasNoInventedLeaseCloseOrCommit(){ + var r=fails(f->f.driver.stallAcquire=true,(f,s)->Mono.just("protected-value"),8);assertEquals(1,r.fixture.driver.acquireCancelled);assertEquals(0,r.fixture.driver.closes);assertFalse(r.error.transactionFacts().commitAcknowledged());assertTrue(r.error.transactionFacts().nativeAttempted()); + } + @Test void stalledBeginClosesExactlyItsCreatedLease(){ + var r=fails(f->f.driver.stallBegin=true,(f,s)->Mono.just("protected-value"),8);assertEquals(1,r.fixture.driver.beginCancelled);assertEquals(1,r.fixture.driver.closes);assertFalse(r.error.transactionFacts().boundaryCompleted());assertFalse(r.error.transactionFacts().commitAcknowledged()); + } + @Test void stalledWorkIsBoundedAndObservedRollbackIsNotBusinessNoEffect(){ + var cancelled=new AtomicInteger();var r=fails(f->{},(f,s)->Mono.never().doOnCancel(cancelled::incrementAndGet),8);assertEquals(1,cancelled.get());assertEquals(1,r.fixture.driver.closes);assertEquals(0,r.fixture.driver.commits);assertTrue(r.fixture.driver.rollbacks>0); + } + @Test void actualCommitPublisherIsCancelledWithinOriginalBudget(){ + var r=fails(f->f.driver.stallCommit=true,(f,s)->Mono.just("protected-value"),8);assertEquals(1,r.fixture.driver.commitCancelled);assertEquals(1,r.fixture.driver.closes);assertFalse(r.error.transactionFacts().commitAcknowledged());assertFalse(r.error.transactionFacts().boundaryCompleted()); + } + @Test void stalledRollbackStillAttemptsCloseWithIndependentFiniteCleanup(){ + var r=fails(f->f.driver.stallRollback=true,(f,s)->Mono.error(new IllegalStateException("protected-value")),10);assertTrue(r.fixture.driver.rollbackCancelled>0);assertEquals(1,r.fixture.driver.closes);assertFalse(r.error.transactionFacts().commitAcknowledged());assertEquals(CleanupFact.INCOMPLETE,r.error.cleanupFact()); + } + @Test void failedRollbackCannotSkipTheOwnedClose(){ + var r=fails(f->f.driver.failRollback=true,(f,s)->Mono.error(new IllegalStateException("protected-value")),8);assertEquals(1,r.fixture.driver.closes);assertFalse(r.error.transactionFacts().rollbackAcknowledged());assertEquals(CleanupFact.INCOMPLETE,r.error.cleanupFact()); + } + @Test void stalledCloseAfterCommitRetainsBothCommitAndBoundaryFactsWithoutValue(){ + var r=fails(f->f.driver.stallClose=true,(f,s)->Mono.just("protected-value"),8);assertEquals(1,r.fixture.driver.closes);assertEquals(1,r.fixture.driver.closeCancelled);assertTrue(r.error.transactionFacts().commitAcknowledged());assertTrue(r.error.transactionFacts().boundaryCompleted());assertEquals(CleanupFact.INCOMPLETE,r.error.cleanupFact()); + } + @Test void afterCommitFailureKeepsAcknowledgmentDistinctFromBoundaryCompletion(){ + var r=fails(f->{},(f,s)->TransactionSynchronizationManager.forCurrentTransaction().flatMap(sync->{sync.registerSynchronization(new TransactionSynchronization(){@Override public Mono afterCommit(){return Mono.error(new IllegalStateException("protected-value"));}});return Mono.just("protected-value");}),8); + assertTrue(r.error.transactionFacts().commitAcknowledged());assertFalse(r.error.transactionFacts().boundaryCompleted());assertEquals(1,r.fixture.driver.closes); + } + @Test void stalledAfterCommitBoundaryKeepsKnownCommitAndCannotRenewItsBudget(){ + var r=fails(f->{},(f,s)->TransactionSynchronizationManager.forCurrentTransaction().flatMap(sync->{sync.registerSynchronization(new TransactionSynchronization(){@Override public Mono afterCommit(){return Mono.never();}});return Mono.just("protected-value");}),8); + assertTrue(r.error.transactionFacts().commitAcknowledged());assertFalse(r.error.transactionFacts().boundaryCompleted());assertEquals(1,r.fixture.driver.closes);assertEquals(0,r.fixture.driver.rollbacks); + } + @Test void finalOriginalBudgetMustStillBeLiveAfterSuccessfulClose(){ + var r=fails(f->f.driver.afterClose=()->f.time.wallOffset.addAndGet(6000),(f,s)->Mono.just("protected-value"),1);assertTrue(r.error.transactionFacts().commitAcknowledged());assertTrue(r.error.transactionFacts().boundaryCompleted());assertEquals(CleanupFact.COMPLETE,r.error.cleanupFact());assertEquals(RootTransactionException.Stage.FINAL_BUDGET,r.error.stage()); + } + @Test void nativeCapWakesThePendingDeadlineInsteadOfWaitingFiveSeconds(){ + var r=fails(f->{},(f,s)->Mono.delay(Duration.ofMillis(100)).flatMap(v->{s.narrow(f.time.budget(400));return Mono.never();}),2);assertEquals(1,r.fixture.driver.closes);assertEquals(0,r.fixture.driver.commits); + } + @Test void laterLongerCapCannotRenewOriginalBudget(){ + var r=fails(f->{},(f,s)->{s.narrow(f.time.budget(10000));return Mono.never();},8);assertEquals(0,r.fixture.driver.commits);assertEquals(1,r.fixture.driver.closes); + } + @Test void foreignFactoryCannotBorrowAnotherInvocationOrBeClosed(){ + var foreign=new Driver();var other=new OwnedLeaseConnectionFactory(foreign);var r=fails(f->{},(f,s)->Mono.from(other.create()).thenReturn("protected-value"),1);assertEquals(0,foreign.creates);assertEquals(0,foreign.closes);assertEquals(1,r.fixture.driver.closes); + } + @Test void wrapperWithoutPrivateInvocationCannotAcquire(){var d=new Driver();StepVerifier.create(Mono.from(new OwnedLeaseConnectionFactory(d).create())).expectError(IllegalStateException.class).verify();assertEquals(0,d.creates);} + @Test void callerCancellationStillClosesItsOwnBeginLeaseOnce(){ + var ref=new AtomicReference();StepVerifier.withVirtualTime(()->{var f=new Fixture();ref.set(f);f.driver.stallBegin=true;return f.execute(s->Mono.just("protected-value"));}).thenAwait(Duration.ofMillis(10)).thenCancel().verify(Duration.ofSeconds(1));assertEquals(1,ref.get().driver.beginCancelled);assertEquals(1,ref.get().driver.closes); + } + @Test void successIsDeliveredOnlyAfterActualCommitBoundaryAndExactClose(){ + var ref=new AtomicReference();StepVerifier.withVirtualTime(()->{var f=new Fixture();ref.set(f);return f.execute(s->Mono.just("protected-value"));}).assertNext(value->{assertEquals("protected-value",value.value());assertEquals("RootCompletion",value.toString());assertTrue(value.transactionFacts().commitAcknowledged());assertTrue(value.transactionFacts().boundaryCompleted());assertEquals(1,ref.get().driver.closes);}).verifyComplete(); + } + @Test void freshSubscriptionsOwnFreshHandlesButCannotResetOriginalExpiry(){ + var ref=new AtomicReference();StepVerifier.withVirtualTime(()->{var f=new Fixture();ref.set(f);var result=f.execute(s->Mono.just("protected-value"));return result.concatWith(result.delaySubscription(Duration.ofSeconds(6)));}).expectNextCount(1).thenAwait(Duration.ofSeconds(7)).expectErrorSatisfies(e->{var failure=(RootTransactionException)e;assertFalse(failure.transactionFacts().nativeAttempted());assertEquals(RootTransactionException.Stage.GATE,failure.stage());}).verify(Duration.ofSeconds(1));assertEquals(1,ref.get().driver.creates);assertEquals(1,ref.get().driver.closes); + } + @Test void freshSubscriptionsInsideBudgetDoNotShareLeaseCloseState(){ + var ref=new AtomicReference();StepVerifier.withVirtualTime(()->{var f=new Fixture();ref.set(f);return f.execute(s->Mono.just("protected-value")).repeat(1);}).expectNextCount(2).verifyComplete();assertEquals(2,ref.get().driver.creates);assertEquals(2,ref.get().driver.closes); + } + @Test void ambientRootIsRefusedBeforeNestedAcquisition(){ + var nested=new AtomicInteger();var r=fails(f->{},(f,s)->f.execute(inner->{nested.incrementAndGet();return Mono.just("protected-value");}).thenReturn("protected-value"),1);assertEquals(0,nested.get());assertEquals(1,r.fixture.driver.creates);assertEquals(1,r.fixture.driver.closes); + } + @Test void genuineAmbientSpringTransactionIsNotJoinedOrClosed(){ + var outer=new Driver();var ref=new AtomicReference();StepVerifier.withVirtualTime(()->{var f=new Fixture();ref.set(f);return TransactionalOperator.create(new R2dbcTransactionManager(outer)).transactional(f.execute(s->Mono.just("protected-value")));}).expectError(RootTransactionException.class).verify();assertEquals(0,ref.get().driver.creates);assertEquals(0,ref.get().driver.closes);assertEquals(1,outer.closes); + } + @Test void emptyWorkResultCannotCommitOrEmitCompletion(){var r=fails(f->{},(f,s)->Mono.empty(),1);assertEquals(0,r.fixture.driver.commits);assertEquals(1,r.fixture.driver.closes);assertTrue(r.error.transactionFacts().rollbackAcknowledged());assertFalse(r.error.transactionFacts().boundaryCompleted());} +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/R2dbcOperationBudgetTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/R2dbcOperationBudgetTest.java new file mode 100644 index 000000000..7b5a59c26 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/R2dbcOperationBudgetTest.java @@ -0,0 +1,24 @@ +package org.fireflyframework.core.transaction; + +import java.time.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class R2dbcOperationBudgetTest { + static final class Time extends Clock { + final Instant base=Instant.parse("2026-10-26T00:00:00Z");final AtomicLong wall=new AtomicLong(),mono=new AtomicLong();final LongSupplier nanos=mono::get; + public Instant instant(){return base.plusMillis(wall.get());}public ZoneId getZone(){return ZoneOffset.UTC;}public Clock withZone(ZoneId zone){return this;} + R2dbcOperationBudget budget(long millis){return R2dbcOperationBudget.observed(instant(),instant().plusMillis(millis),mono.get(),Duration.ofMillis(millis),this,nanos);} + } + @Test void elapsedTimeCannotBeResetByRemainingCalls(){var t=new Time();var b=t.budget(1000);t.mono.set(400_000_000);assertEquals(Duration.ofMillis(600),b.remaining());t.mono.set(900_000_000);assertEquals(Duration.ofMillis(100),b.remaining());} + @Test void earlierWallClockExpiryWinsOverMonotonicLimit(){var t=new Time();var b=t.budget(1000);t.wall.set(999);assertEquals(Duration.ofMillis(1),b.remaining());t.wall.set(1000);assertThrows(IllegalStateException.class,b::remaining);} + @Test void backwardWallClockIsRefusedInsteadOfExtendingTime(){var t=new Time();var b=t.budget(1000);t.wall.set(-1);assertThrows(IllegalStateException.class,b::remaining);} + @Test void negativeMonotonicElapsedIsRefused(){var t=new Time();var b=t.budget(1000);t.mono.set(-1);assertThrows(IllegalStateException.class,b::remaining);} + @Test void zeroNegativeAndOverMaximumDurationsAreRejected(){var t=new Time();for(long millis:new long[]{0,-1,1001})assertThrows(IllegalArgumentException.class,()->R2dbcOperationBudget.observed(t.base,t.base.plusMillis(millis),0,Duration.ofSeconds(1),t,t.nanos));} + @Test void unrepresentableNanosAreRejectedBeforeAnyTimerOrResource(){var t=new Time();assertThrows(ArithmeticException.class,()->R2dbcOperationBudget.observed(t.base,t.base.plus(Duration.ofDays(200000)),0,Duration.ofDays(200000),t,t.nanos));} + @Test void incompatibleClockOrMonotonicSourcesCannotReplaceAnOriginalCap(){var a=new Time();var b=new Time();assertThrows(IllegalArgumentException.class,()->a.budget(1000).intersect(b.budget(1000)));} + @Test void intersectionRetainsEarlierOriginalAndNativeCaps(){var t=new Time();var original=t.budget(1000);var shorter=t.budget(200);var cap=original.intersect(shorter);t.mono.set(100_000_000);assertEquals(Duration.ofMillis(100),cap.remaining());assertEquals(Duration.ofMillis(900),original.remaining());} + @Test void cleanupLimitsMustBeFiniteAndStrictlyPositive(){assertThrows(IllegalArgumentException.class,()->new CleanupPolicy(Duration.ZERO,Duration.ofSeconds(1)));assertThrows(ArithmeticException.class,()->new CleanupPolicy(Duration.ofDays(200000),Duration.ofSeconds(1)));} +} From 5da837fcb3aca2569ee9ede5b31c2f949eae1300 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Mon, 26 Oct 2026 19:13:11 -0700 Subject: [PATCH 31/44] Expose budget intersections for pre-acquisition owner bounds --- docs/site/modules/r2dbc.md | 2 +- modules/r2dbc/README.md | 2 +- .../transaction/R2dbcOperationBudget.java | 3 +- .../PublicBudgetIntersectionTest.java | 42 +++++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/PublicBudgetIntersectionTest.java diff --git a/docs/site/modules/r2dbc.md b/docs/site/modules/r2dbc.md index 8ce78774d..e2751a90c 100644 --- a/docs/site/modules/r2dbc.md +++ b/docs/site/modules/r2dbc.md @@ -52,7 +52,7 @@ The source tree provides `BoundedRootTransaction` and `impl.DefaultBoundedRootTr Build one `OwnedLeaseConnectionFactory` around the application connection factory, then construct the actual `DatabaseClient`, customized `R2dbcEntityTemplate`, Spring Data repositories, and `R2dbcTransactionManager` with that same wrapper. `BoundedR2dbcResources.verify` checks the factory/client/template/manager identities; preserve the host converter, callbacks, and repository configuration. The helper refuses an ambient transaction rather than joining another owner's work. It owns individual acquired leases and never closes the application pool. -Capture the monotonic anchor before obtaining an original observation. Pass its original observation time, absolute expiration, explicit maximum duration, clock, and the same monotonic supplier to `R2dbcOperationBudget.observed`. Execute with a `PROPAGATION_REQUIRED` transaction definition and return one result; other propagation modes are refused. Use `RootTransactionScope.narrow` when a later native observation or dependency imposes an earlier bound. Original and narrowed budgets must share the identical `Clock` object and monotonic supplier. Budget checks include acquisition, transaction completion, and final cleanup; an expired budget does not grant a fresh timeout. Configure rollback and close limits separately with `CleanupPolicy`. +Capture the monotonic anchor before obtaining an original observation. Pass its original observation time, absolute expiration, explicit maximum duration, clock, and the same monotonic supplier to `R2dbcOperationBudget.observed`. Execute with a `PROPAGATION_REQUIRED` transaction definition and return one result; other propagation modes are refused. Compose an already observed dependency limit with `original.intersect(inherited)` before passing the budget to `execute`, so connection acquisition and transaction begin use the earlier bound. Use `RootTransactionScope.narrow` when a later native observation or dependency imposes an earlier bound. Original and narrowed budgets must share the identical `Clock` object and monotonic supplier. Budget checks include acquisition, transaction completion, and final cleanup; an expired budget does not grant a fresh timeout. Configure rollback and close limits separately with `CleanupPolicy`. `RootCompletion` exposes a value only after successful bounded completion. `RootTransactionException` exposes payload-free `TransactionFacts` and `CleanupFact`: commit acknowledgment and completed transaction boundary are separate observations. Timeout, cancellation, or cleanup failure can leave the business outcome uncertain. The application must recover through its authoritative action/receipt protocol. These technical facts establish no authentication, authorization, financial finality, or proof that no business effect occurred. diff --git a/modules/r2dbc/README.md b/modules/r2dbc/README.md index 962139393..f446f254c 100644 --- a/modules/r2dbc/README.md +++ b/modules/r2dbc/README.md @@ -52,7 +52,7 @@ The source tree provides `BoundedRootTransaction` and `impl.DefaultBoundedRootTr Build one `OwnedLeaseConnectionFactory` around the application connection factory, then construct the actual `DatabaseClient`, customized `R2dbcEntityTemplate`, Spring Data repositories, and `R2dbcTransactionManager` with that same wrapper. `BoundedR2dbcResources.verify` checks the factory/client/template/manager identities; preserve the host converter, callbacks, and repository configuration. The helper refuses an ambient transaction rather than joining another owner's work. It owns individual acquired leases and never closes the application pool. -Capture the monotonic anchor before obtaining an original observation. Pass its original observation time, absolute expiration, explicit maximum duration, clock, and the same monotonic supplier to `R2dbcOperationBudget.observed`. Execute with a `PROPAGATION_REQUIRED` transaction definition and return one result; other propagation modes are refused. Use `RootTransactionScope.narrow` when a later native observation or dependency imposes an earlier bound. Original and narrowed budgets must share the identical `Clock` object and monotonic supplier. Budget checks include acquisition, transaction completion, and final cleanup; an expired budget does not grant a fresh timeout. Configure rollback and close limits separately with `CleanupPolicy`. +Capture the monotonic anchor before obtaining an original observation. Pass its original observation time, absolute expiration, explicit maximum duration, clock, and the same monotonic supplier to `R2dbcOperationBudget.observed`. Execute with a `PROPAGATION_REQUIRED` transaction definition and return one result; other propagation modes are refused. Compose an already observed dependency limit with `original.intersect(inherited)` before passing the budget to `execute`, so connection acquisition and transaction begin use the earlier bound. Use `RootTransactionScope.narrow` when a later native observation or dependency imposes an earlier bound. Original and narrowed budgets must share the identical `Clock` object and monotonic supplier. Budget checks include acquisition, transaction completion, and final cleanup; an expired budget does not grant a fresh timeout. Configure rollback and close limits separately with `CleanupPolicy`. `RootCompletion` exposes a value only after successful bounded completion. `RootTransactionException` exposes payload-free `TransactionFacts` and `CleanupFact`: commit acknowledgment and completed transaction boundary are separate observations. Timeout, cancellation, or cleanup failure can leave the business outcome uncertain. The application must recover through its authoritative action/receipt protocol. These technical facts establish no authentication, authorization, financial finality, or proof that no business effect occurred. diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/R2dbcOperationBudget.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/R2dbcOperationBudget.java index f298cd22a..2a46cfb71 100644 --- a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/R2dbcOperationBudget.java +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/R2dbcOperationBudget.java @@ -28,7 +28,8 @@ public Duration remaining(){ if(elapsed<0||elapsed>=budgetNanos||now.isBefore(observedAt)||!now.isBefore(until))throw new IllegalStateException("Original operation budget exhausted"); var absolute=Duration.between(now,until);var monotonic=Duration.ofNanos(budgetNanos-elapsed);return absolute.compareTo(monotonic)<0?absolute:monotonic; } - R2dbcOperationBudget intersect(R2dbcOperationBudget other){ + /** Compose inherited limits before acquisition; both budgets must share the same time sources. */ + public R2dbcOperationBudget intersect(R2dbcOperationBudget other){ Objects.requireNonNull(other); if(clock!=other.clock||nanos!=other.nanos)throw new IllegalArgumentException("Compatible original time sources required"); long before=nanos.getAsLong();var a=remaining();var b=other.remaining();var remaining=a.compareTo(b)<0?a:b;var absolute=until.isBefore(other.until)?until:other.until; diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/PublicBudgetIntersectionTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/PublicBudgetIntersectionTest.java new file mode 100644 index 000000000..98016e649 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/PublicBudgetIntersectionTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.core.transaction; + +import static org.junit.jupiter.api.Assertions.*; +import java.lang.reflect.InvocationTargetException; +import java.time.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; +import org.junit.jupiter.api.Test; + +class PublicBudgetIntersectionTest { + private static final Instant START=Instant.parse("2026-10-27T00:00:00Z"); + private final Clock clock=Clock.fixed(START,ZoneOffset.UTC); + private final AtomicLong elapsed=new AtomicLong(); + private final LongSupplier nanos=elapsed::get; + + private R2dbcOperationBudget budget(long seconds){return R2dbcOperationBudget.observed(START,START.plusSeconds(seconds),0,Duration.ofSeconds(5),clock,nanos);} + private static R2dbcOperationBudget compose(R2dbcOperationBudget first,R2dbcOperationBudget second){ + var method=assertDoesNotThrow(()->R2dbcOperationBudget.class.getMethod("intersect",R2dbcOperationBudget.class),"Owners require public budget composition before acquisition starts"); + try{return (R2dbcOperationBudget)method.invoke(first,second);} + catch(InvocationTargetException error){if(error.getCause() instanceof RuntimeException cause)throw cause;throw new AssertionError(error.getCause());} + catch(ReflectiveOperationException error){throw new AssertionError(error);} + } + @Test void composedBudgetKeepsTheEarlierOriginalMonotonicDeadline(){ + var original=budget(5);var inherited=budget(1);elapsed.set(250_000_000); + var combined=compose(original,inherited);assertEquals(Duration.ofMillis(750),combined.remaining()); + elapsed.set(1_000_000_000);assertThrows(IllegalStateException.class,combined::remaining); + assertEquals(Duration.ofSeconds(4),original.remaining()); + } + @Test void expiredInheritedBudgetRefusesCompositionBeforeAnyTransactionCanStart(){ + var original=budget(5);var inherited=budget(1);elapsed.set(1_000_000_000); + assertThrows(IllegalStateException.class,()->compose(original,inherited)); + } + @Test void differentTimeSourceObjectsCannotBeComposed(){ + var other=R2dbcOperationBudget.observed(START,START.plusSeconds(1),0,Duration.ofSeconds(5),Clock.fixed(START,ZoneOffset.UTC),nanos); + assertThrows(IllegalArgumentException.class,()->compose(budget(5),other)); + } +} From 5cd8ec4c055a2595012a05cb8acfae650658f51d Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Mon, 26 Oct 2026 21:18:35 -0700 Subject: [PATCH 32/44] Escape webhook guard diagnostic fields without changing request behavior --- .../ratelimit/WebhookRateLimitService.java | 5 +- .../ResilientWebhookProcessingService.java | 9 +- .../core/validation/WebhookValidator.java | 11 +- .../core/logging/WebhookGuardLoggingTest.java | 224 ++++++++++++++++++ 4 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/logging/WebhookGuardLoggingTest.java diff --git a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/ratelimit/WebhookRateLimitService.java b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/ratelimit/WebhookRateLimitService.java index 0d128080e..8991478ee 100644 --- a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/ratelimit/WebhookRateLimitService.java +++ b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/ratelimit/WebhookRateLimitService.java @@ -22,6 +22,7 @@ import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.fireflyframework.kernel.logging.LogFields; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -52,7 +53,7 @@ public Mono executeWithRateLimit(String providerName, Mono operation) return operation .transformDeferred(RateLimiterOperator.of(rateLimiter)) .onErrorMap(RequestNotPermitted.class, e -> { - log.warn("Rate limit exceeded for provider: {}", providerName); + log.warn("Rate limit exceeded for provider: {}", LogFields.text(providerName)); return new ResponseStatusException( HttpStatus.TOO_MANY_REQUESTS, "Rate limit exceeded for provider: " + providerName + @@ -75,7 +76,7 @@ public Mono executeWithIpRateLimit(String sourceIp, Mono operation) { return operation .transformDeferred(RateLimiterOperator.of(rateLimiter)) .onErrorMap(RequestNotPermitted.class, e -> { - log.warn("Rate limit exceeded for IP: {}", sourceIp); + log.warn("Rate limit exceeded for IP: {}", LogFields.text(sourceIp)); return new ResponseStatusException( HttpStatus.TOO_MANY_REQUESTS, "Rate limit exceeded for your IP address. Please retry after some time." diff --git a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/resilience/ResilientWebhookProcessingService.java b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/resilience/ResilientWebhookProcessingService.java index edd4d2d23..9383aa06f 100644 --- a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/resilience/ResilientWebhookProcessingService.java +++ b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/resilience/ResilientWebhookProcessingService.java @@ -17,6 +17,7 @@ package org.fireflyframework.webhooks.core.resilience; import org.fireflyframework.webhooks.core.services.WebhookProcessingService; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.webhooks.interfaces.dto.WebhookEventDTO; import org.fireflyframework.webhooks.interfaces.dto.WebhookResponseDTO; import io.github.resilience4j.circuitbreaker.CircuitBreaker; @@ -73,7 +74,7 @@ public ResilientWebhookProcessingService( @Override public Mono processWebhook(WebhookEventDTO eventDto) { log.debug("Processing webhook with resilience patterns: eventId={}, provider={}", - eventDto.getEventId(), eventDto.getProviderName()); + eventDto.getEventId(), LogFields.text(eventDto.getProviderName())); return delegate.processWebhook(eventDto) // Apply time limiter first (inner decorator) @@ -93,7 +94,7 @@ public Mono processWebhook(WebhookEventDTO eventDto) { */ private Mono handleResilienceError(Throwable error) { if (error instanceof TimeoutException) { - log.error("Webhook processing timed out: {}", error.getMessage()); + log.error("Webhook processing timed out: {}", LogFields.text(error.getMessage())); return Mono.error(new ResponseStatusException( HttpStatus.GATEWAY_TIMEOUT, "Webhook processing timed out. The message broker may be slow or unavailable." @@ -101,7 +102,7 @@ private Mono handleResilienceError(Throwable error) { } if (error instanceof io.github.resilience4j.circuitbreaker.CallNotPermittedException) { - log.error("Circuit breaker is OPEN - rejecting webhook: {}", error.getMessage()); + log.error("Circuit breaker is OPEN - rejecting webhook: {}", LogFields.text(error.getMessage())); return Mono.error(new ResponseStatusException( HttpStatus.SERVICE_UNAVAILABLE, "Webhook processing is temporarily unavailable. The message broker is experiencing issues. Please retry later." @@ -109,7 +110,7 @@ private Mono handleResilienceError(Throwable error) { } // For other errors, let them propagate - log.error("Error in resilient webhook processing: {}", error.getMessage(), error); + log.error("Error in resilient webhook processing: {}", LogFields.text(error.getMessage()), error); return Mono.error(error); } diff --git a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/validation/WebhookValidator.java b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/validation/WebhookValidator.java index 703fdbed4..a49b06290 100644 --- a/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/validation/WebhookValidator.java +++ b/modules/webhooks/fireflyframework-webhooks-core/src/main/java/org/fireflyframework/webhooks/core/validation/WebhookValidator.java @@ -17,6 +17,7 @@ package org.fireflyframework.webhooks.core.validation; import com.fasterxml.jackson.databind.JsonNode; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.webhooks.core.config.WebhookSecurityProperties; import lombok.extern.slf4j.Slf4j; import org.apache.commons.net.util.SubnetUtils; @@ -87,7 +88,7 @@ public void validateProviderName(String providerName) { } if (!providerNamePattern.matcher(providerName).matches()) { - log.warn("Invalid provider name format: {}", providerName); + log.warn("Invalid provider name format: {}", LogFields.text(providerName)); throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid provider name format. Must match pattern: " + securityProperties.getProviderNamePattern() @@ -153,7 +154,7 @@ public void validateContentType(ServerHttpRequest request) { List allowedTypes = securityProperties.getAllowedContentTypes(); if (!allowedTypes.contains(baseContentType)) { - log.warn("Invalid Content-Type: {}. Allowed types: {}", baseContentType, allowedTypes); + log.warn("Invalid Content-Type: {}. Allowed types: {}", LogFields.text(baseContentType), LogFields.values(allowedTypes)); throw new ResponseStatusException( HttpStatus.UNSUPPORTED_MEDIA_TYPE, "Content-Type must be one of: " + String.join(", ", allowedTypes) @@ -186,14 +187,14 @@ public void validateIpWhitelist(String providerName, ServerHttpRequest request) .anyMatch(allowedIp -> isIpMatch(sourceIp, allowedIp)); if (!isWhitelisted) { - log.warn("IP {} not whitelisted for provider {}", sourceIp, providerName); + log.warn("IP {} not whitelisted for provider {}", LogFields.text(sourceIp), LogFields.text(providerName)); throw new ResponseStatusException( HttpStatus.FORBIDDEN, "Source IP not authorized for this provider" ); } - log.debug("IP whitelist validation passed: {} for provider {}", sourceIp, providerName); + log.debug("IP whitelist validation passed: {} for provider {}", LogFields.text(sourceIp), LogFields.text(providerName)); } /** @@ -237,7 +238,7 @@ private boolean isIpMatch(String sourceIp, String allowedIp) { return sourceIp.equals(allowedIp); } } catch (IllegalArgumentException e) { - log.warn("Invalid IP or CIDR notation: allowedIp={}, error={}", allowedIp, e.getMessage()); + log.warn("Invalid IP or CIDR notation: allowedIp={}, error={}", LogFields.text(allowedIp), LogFields.text(e.getMessage())); return false; } } diff --git a/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/logging/WebhookGuardLoggingTest.java b/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/logging/WebhookGuardLoggingTest.java new file mode 100644 index 000000000..6936524ea --- /dev/null +++ b/modules/webhooks/fireflyframework-webhooks-core/src/test/java/org/fireflyframework/webhooks/core/logging/WebhookGuardLoggingTest.java @@ -0,0 +1,224 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.webhooks.core.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import io.github.resilience4j.timelimiter.TimeLimiterRegistry; +import org.fireflyframework.kernel.logging.LogFields; +import org.fireflyframework.webhooks.core.config.WebhookSecurityProperties; +import org.fireflyframework.webhooks.core.ratelimit.WebhookRateLimitService; +import org.fireflyframework.webhooks.core.resilience.ResilientWebhookProcessingService; +import org.fireflyframework.webhooks.core.services.WebhookProcessingService; +import org.fireflyframework.webhooks.core.validation.WebhookValidator; +import org.fireflyframework.webhooks.interfaces.dto.WebhookEventDTO; +import org.fireflyframework.webhooks.interfaces.dto.WebhookResponseDTO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class WebhookGuardLoggingTest { + private static final String LABEL = "sandbox\r\nlabel\u000b\f\u0085\u2028\u2029"; + private final ListAppender events = new ListAppender<>(); + private final Map levels = new HashMap<>(); + private final Map additive = new HashMap<>(); + + @BeforeEach void capture() { + events.start(); + for (Class type : List.of(WebhookValidator.class, WebhookRateLimitService.class, ResilientWebhookProcessingService.class)) { + var logger = (Logger) LoggerFactory.getLogger(type); + levels.put(logger, logger.getLevel()); additive.put(logger, logger.isAdditive()); + logger.setLevel(Level.DEBUG); logger.setAdditive(false); logger.addAppender(events); + } + } + + @AfterEach void restore() { + for (var logger : levels.keySet()) { + logger.detachAppender(events); logger.setLevel(levels.get(logger)); logger.setAdditive(additive.get(logger)); + } + events.stop(); + } + + @Test void invalidProviderKeepsBadRequestAndEscapesOnlyDiagnostic() { + var properties = new WebhookSecurityProperties(); + var failure = assertThrows(ResponseStatusException.class, () -> new WebhookValidator(properties).validateProviderName(LABEL)); + assertEquals(HttpStatus.BAD_REQUEST, failure.getStatusCode()); + assertEquals("Invalid provider name format. Must match pattern: " + properties.getProviderNamePattern(), failure.getReason()); + diagnostic("Invalid provider name format: {}", Level.WARN, "Invalid provider name format: " + LogFields.text(LABEL)); + singleLine(); + } + + @Test void deniedIpPreservesRawProviderLookupHeaderAndForbiddenResponse() { + var properties = whitelist(LABEL, List.of("127.0.0.1")); + var request = request("X-Forwarded-For", "sandbox\naddress"); + var failure = assertThrows(ResponseStatusException.class, () -> new WebhookValidator(properties).validateIpWhitelist(LABEL, request)); + assertEquals(HttpStatus.FORBIDDEN, failure.getStatusCode()); assertEquals("Source IP not authorized for this provider", failure.getReason()); + assertEquals("sandbox\naddress", request.getHeaders().getFirst("X-Forwarded-For")); + assertEquals(List.of("127.0.0.1"), properties.getIpWhitelist().get(LABEL)); + diagnostic("IP {} not whitelisted for provider {}", Level.WARN, "IP sandbox\\naddress not whitelisted for provider " + LogFields.text(LABEL)); + singleLine(); + } + + @Test void acceptedIpKeepsRawComparisonAndEscapesDebugArguments() { + String ip = "sandbox\naddress"; + var properties = whitelist(LABEL, List.of(ip)); var request = request("X-Forwarded-For", ip); + assertDoesNotThrow(() -> new WebhookValidator(properties).validateIpWhitelist(LABEL, request)); + assertEquals(ip, request.getHeaders().getFirst("X-Forwarded-For")); + diagnostic("IP whitelist validation passed: {} for provider {}", Level.DEBUG, + "IP whitelist validation passed: sandbox\\naddress for provider " + LogFields.text(LABEL)); + singleLine(); + } + + @Test void malformedCidrAndLibraryMessageRemainARefusalWithoutMultilineDiagnostics() { + String cidr = "sandbox\nnetwork/24"; var properties = whitelist("sandbox", List.of(cidr)); + var failure = assertThrows(ResponseStatusException.class, + () -> new WebhookValidator(properties).validateIpWhitelist("sandbox", request("X-Forwarded-For", "127.0.0.1"))); + assertEquals(HttpStatus.FORBIDDEN, failure.getStatusCode()); assertEquals(cidr, properties.getIpWhitelist().get("sandbox").getFirst()); + var warning = event("Invalid IP or CIDR notation: allowedIp={}, error={}"); + assertEquals(Level.WARN, warning.getLevel()); assertTrue(warning.getFormattedMessage().contains(LogFields.text(cidr))); + assertNull(warning.getThrowableProxy()); singleLine(); + } + + @Test void contentTypeAndConfiguredListEscapeOnlyLogRendering() { + String type = "text/sandbox\nlabel"; var properties = new WebhookSecurityProperties(); + var allowed = List.of("application/sandbox\nconfig"); properties.setAllowedContentTypes(allowed); + var failure = assertThrows(ResponseStatusException.class, + () -> new WebhookValidator(properties).validateContentType(request("Content-Type", type))); + assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE, failure.getStatusCode()); + assertEquals("Content-Type must be one of: " + String.join(", ", allowed), failure.getReason()); assertSame(allowed, properties.getAllowedContentTypes()); + diagnostic("Invalid Content-Type: {}. Allowed types: {}", Level.WARN, + "Invalid Content-Type: " + LogFields.text(type) + ". Allowed types: " + LogFields.values(allowed)); singleLine(); + } + + @Test void providerRateLimitKeepsRawKeyOperationAndOriginalReason() { + var registry = rateRegistry(); var service = new WebhookRateLimitService(registry); Object value = new Object(); + assertSame(value, service.executeWithRateLimit(LABEL, Mono.just(value)).block()); + var failure = assertThrows(ResponseStatusException.class, () -> service.executeWithRateLimit(LABEL, Mono.just(value)).block()); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, failure.getStatusCode()); + assertEquals("Rate limit exceeded for provider: " + LABEL + ". Please retry after some time.", failure.getReason()); + assertEquals("webhook-provider-" + LABEL, registry.getAllRateLimiters().iterator().next().getName()); + diagnostic("Rate limit exceeded for provider: {}", Level.WARN, "Rate limit exceeded for provider: " + LogFields.text(LABEL)); singleLine(); + } + + @Test void ipRateLimitKeepsRawKeyAndTooManyRequests() { + var registry = rateRegistry(); var service = new WebhookRateLimitService(registry); Object value = new Object(); + assertSame(value, service.executeWithIpRateLimit(LABEL, Mono.just(value)).block()); + var failure = assertThrows(ResponseStatusException.class, () -> service.executeWithIpRateLimit(LABEL, Mono.just(value)).block()); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, failure.getStatusCode()); + assertEquals("Rate limit exceeded for your IP address. Please retry after some time.", failure.getReason()); + assertEquals("webhook-ip-" + LABEL, registry.getAllRateLimiters().iterator().next().getName()); + diagnostic("Rate limit exceeded for IP: {}", Level.WARN, "Rate limit exceeded for IP: " + LogFields.text(LABEL)); singleLine(); + } + + @Test void resilienceProviderDiagnosticPreservesDelegateDtoPayloadAndResponse() { + var dto = dto(LABEL); var payload = dto.getPayload(); var response = WebhookResponseDTO.success(dto.getEventId(), LABEL); + var delegate = mock(WebhookProcessingService.class); when(delegate.processWebhook(dto)).thenReturn(Mono.just(response)); + assertSame(response, resilient(delegate).processWebhook(dto).block()); verify(delegate).processWebhook(same(dto)); + assertEquals(LABEL, dto.getProviderName()); assertSame(payload, dto.getPayload()); + diagnostic("Processing webhook with resilience patterns: eventId={}, provider={}", Level.DEBUG, + "Processing webhook with resilience patterns: eventId=" + dto.getEventId() + ", provider=" + LogFields.text(LABEL)); singleLine(); + } + + @Test void genericFailurePreservesThrowableIdentityAndEscapesFormattedMessage() { + var dto = dto("sandbox"); var original = new IllegalStateException(LABEL); + var delegate = mock(WebhookProcessingService.class); when(delegate.processWebhook(dto)).thenReturn(Mono.error(original)); + assertSame(original, assertThrows(IllegalStateException.class, () -> resilient(delegate).processWebhook(dto).block())); + var error = event("Error in resilient webhook processing: {}"); + assertEquals(Level.ERROR, error.getLevel()); assertSame(original, ((ThrowableProxy) error.getThrowableProxy()).getThrowable()); + assertEquals("Error in resilient webhook processing: " + LogFields.text(LABEL), error.getFormattedMessage()); + assertEquals(LABEL, original.getMessage()); singleLine(); + } + + @Test void timeoutKeepsGatewayTimeoutAndExistingReason() { + var dto = dto("sandbox"); var delegate = mock(WebhookProcessingService.class); + when(delegate.processWebhook(dto)).thenReturn(Mono.error(new TimeoutException(LABEL))); + var failure = assertThrows(ResponseStatusException.class, () -> resilient(delegate).processWebhook(dto).block()); + assertEquals(HttpStatus.GATEWAY_TIMEOUT, failure.getStatusCode()); + assertEquals("Webhook processing timed out. The message broker may be slow or unavailable.", failure.getReason()); + diagnostic("Webhook processing timed out: {}", Level.ERROR, "Webhook processing timed out: " + LogFields.text(LABEL)); singleLine(); + } + + @Test void circuitFailureKeepsServiceUnavailableAndEscapesLibraryMessage() { + var dto = dto("sandbox"); var delegate = mock(WebhookProcessingService.class); + var original = CallNotPermittedException.createCallNotPermittedException(CircuitBreaker.ofDefaults("sandbox\nbreaker")); + when(delegate.processWebhook(dto)).thenReturn(Mono.error(original)); + var failure = assertThrows(ResponseStatusException.class, () -> resilient(delegate).processWebhook(dto).block()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, failure.getStatusCode()); + assertEquals("Webhook processing is temporarily unavailable. The message broker is experiencing issues. Please retry later.", failure.getReason()); + diagnostic("Circuit breaker is OPEN - rejecting webhook: {}", Level.ERROR, + "Circuit breaker is OPEN - rejecting webhook: " + LogFields.text(original.getMessage())); singleLine(); + } + + @Test void ordinaryAndNullValuesKeepExistingTextAndErrorIdentity() { + var dto = dto(null); var original = new IllegalArgumentException(); + var delegate = mock(WebhookProcessingService.class); when(delegate.processWebhook(dto)).thenReturn(Mono.error(original)); + assertSame(original, assertThrows(IllegalArgumentException.class, () -> resilient(delegate).processWebhook(dto).block())); + assertEquals("Error in resilient webhook processing: null", event("Error in resilient webhook processing: {}").getFormattedMessage()); + diagnostic("Processing webhook with resilience patterns: eventId={}, provider={}", Level.DEBUG, + "Processing webhook with resilience patterns: eventId=" + dto.getEventId() + ", provider=null"); + assertDoesNotThrow(() -> new WebhookValidator(new WebhookSecurityProperties()).validateProviderName("sandbox")); singleLine(); + } + + @Test void disabledLoggingLeavesValidationRateLimitAndDelegateBehaviorUnchanged() { + levels.keySet().forEach(logger -> logger.setLevel(Level.OFF)); + assertThrows(ResponseStatusException.class, () -> new WebhookValidator(new WebhookSecurityProperties()).validateProviderName(LABEL)); + Object value = new Object(); assertSame(value, new WebhookRateLimitService(rateRegistry()).executeWithRateLimit(LABEL, Mono.just(value)).block()); + var dto = dto(LABEL); var response = WebhookResponseDTO.success(dto.getEventId(), LABEL); + var delegate = mock(WebhookProcessingService.class); when(delegate.processWebhook(dto)).thenReturn(Mono.just(response)); + assertSame(response, resilient(delegate).processWebhook(dto).block()); verify(delegate).processWebhook(same(dto)); assertTrue(events.list.isEmpty()); + } + + private static WebhookSecurityProperties whitelist(String provider, List ips) { + var properties = new WebhookSecurityProperties(); properties.setEnableIpWhitelist(true); properties.setIpWhitelist(Map.of(provider, ips)); return properties; + } + private static ServerHttpRequest request(String name, String value) { + var headers = new HttpHeaders(); headers.add(name, value); var request = mock(ServerHttpRequest.class); when(request.getHeaders()).thenReturn(headers); return request; + } + private static RateLimiterRegistry rateRegistry() { + return RateLimiterRegistry.of(RateLimiterConfig.custom().limitForPeriod(1).limitRefreshPeriod(Duration.ofHours(1)).timeoutDuration(Duration.ZERO).build()); + } + private static ResilientWebhookProcessingService resilient(WebhookProcessingService delegate) { + return new ResilientWebhookProcessingService(delegate, CircuitBreakerRegistry.ofDefaults(), TimeLimiterRegistry.ofDefaults()); + } + private static WebhookEventDTO dto(String provider) { + return WebhookEventDTO.builder().eventId(UUID.randomUUID()).providerName(provider).payload(JsonNodeFactory.instance.objectNode().put("kind", "sandbox")).build(); + } + private ILoggingEvent event(String template) { + return events.list.stream().filter(value -> template.equals(value.getMessage())).findFirst().orElseThrow(); + } + private void diagnostic(String template, Level level, String formatted) { + var actual = event(template); assertEquals(level, actual.getLevel()); assertEquals(formatted, actual.getFormattedMessage()); + } + private void singleLine() { + for (var event : events.list) for (String separator : List.of("\r", "\n", "\u000b", "\f", "\u0085", "\u2028", "\u2029")) + assertFalse(event.getFormattedMessage().contains(separator), "Formatted diagnostic must retain a single line"); + } +} From a387cdbfcb9c5b44e5a50e787257f7140088a3e4 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Mon, 26 Oct 2026 22:36:30 -0700 Subject: [PATCH 33/44] Escape job and enrichment diagnostic fields without changing payloads --- .../controller/SmartEnrichmentController.java | 48 ++-- .../data/event/JobEventPublisher.java | 10 +- .../logging/JobAndSmartLogSafetyTest.java | 218 ++++++++++++++++++ 3 files changed, 249 insertions(+), 27 deletions(-) create mode 100644 modules/starter-data/src/test/java/org/fireflyframework/data/logging/JobAndSmartLogSafetyTest.java diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/SmartEnrichmentController.java b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/SmartEnrichmentController.java index b50d2aab9..eca325460 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/SmartEnrichmentController.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/SmartEnrichmentController.java @@ -16,6 +16,8 @@ package org.fireflyframework.data.controller; +import org.fireflyframework.kernel.logging.LogFields; + import org.fireflyframework.data.cache.EnrichmentCacheService; import org.fireflyframework.data.model.EnrichmentApiRequest; import org.fireflyframework.data.model.EnrichmentApiResponse; @@ -160,7 +162,7 @@ public Mono> previewEnrichment( @RequestBody EnrichmentApiRequest apiRequest) { log.info("Received preview request - type: {}, tenantId: {}", - apiRequest.getType(), apiRequest.getTenantId()); + LogFields.text(apiRequest.getType()), apiRequest.getTenantId()); return Mono.fromCallable(() -> { DataEnricher enricher = enricherRegistry.getEnricherForTypeAndTenant( @@ -170,7 +172,7 @@ public Mono> previewEnrichment( if (enricher == null) { log.warn("No enricher found for preview - type: {}, tenantId: {}", - apiRequest.getType(), apiRequest.getTenantId()); + LogFields.text(apiRequest.getType()), apiRequest.getTenantId()); return ResponseEntity.notFound().build(); } @@ -189,7 +191,7 @@ public Mono> previewEnrichment( .build(); log.info("Preview generated - provider: {}, priority: {}, cached: {}", - preview.getProviderName(), preview.getPriority(), preview.isCached()); + LogFields.text(preview.getProviderName()), preview.getPriority(), preview.isCached()); return ResponseEntity.ok(preview); }); @@ -235,9 +237,9 @@ public Mono smartEnrich( @RequestBody EnrichmentApiRequest request) { log.info("Received smart enrichment request - type: {}, tenantId: {}, requestId: {}", - request.getType(), + LogFields.text(request.getType()), request.getTenantId(), - request.getRequestId()); + LogFields.text(request.getRequestId())); // Validate request if (request.getType() == null || request.getType().isBlank()) { @@ -257,14 +259,14 @@ public Mono smartEnrich( request.getType(), request.getTenantId() ); - log.warn(message); + log.warn(LogFields.text(message)); return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }); log.info("Selected enricher: {} (priority: {}) for type: {}, tenant: {}", - enricher.getProviderName(), + LogFields.text(enricher.getProviderName()), enricher.getPriority(), - request.getType(), + LogFields.text(request.getType()), request.getTenantId()); // Convert API request to domain request @@ -276,25 +278,25 @@ public Mono smartEnrich( .doOnSuccess(response -> { if (response.isSuccess()) { log.info("Smart enrichment completed successfully - type: {}, provider: {}, priority: {}, fieldsEnriched: {}, requestId: {}", - response.getType(), - response.getProviderName(), + LogFields.text(response.getType()), + LogFields.text(response.getProviderName()), enricher.getPriority(), response.getFieldsEnriched(), - response.getRequestId()); + LogFields.text(response.getRequestId())); } else { log.warn("Smart enrichment completed with failure - type: {}, provider: {}, message: {}, requestId: {}", - response.getType(), - response.getProviderName(), - response.getMessage(), - response.getRequestId()); + LogFields.text(response.getType()), + LogFields.text(response.getProviderName()), + LogFields.text(response.getMessage()), + LogFields.text(response.getRequestId())); } }) .doOnError(error -> { log.error("Smart enrichment failed for type {} with provider {} and requestId {}: {}", - request.getType(), - enricher.getProviderName(), - request.getRequestId(), - error.getMessage(), + LogFields.text(request.getType()), + LogFields.text(enricher.getProviderName()), + LogFields.text(request.getRequestId()), + LogFields.text(error.getMessage()), error); }); } @@ -412,13 +414,13 @@ public Flux smartEnrichBatch( first.getType(), first.getTenantId() ); - log.warn(message); + log.warn(LogFields.text(message)); return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }); log.info("Processing {} requests with enricher: {} (priority: {})", group.size(), - enricher.getProviderName(), + LogFields.text(enricher.getProviderName()), enricher.getPriority()); // Convert to domain requests @@ -432,7 +434,7 @@ public Flux smartEnrichBatch( .onErrorResume(error -> { // Handle individual errors gracefully log.error("Error in batch enrichment for provider {}: {}", - enricher.getProviderName(), error.getMessage()); + LogFields.text(enricher.getProviderName()), LogFields.text(error.getMessage())); EnrichmentApiResponse errorResponse = EnrichmentApiResponse.builder() .success(false) @@ -518,7 +520,7 @@ public Flux> streamEnrichment( }) .onErrorResume(error -> { log.error("SSE stream enrichment failed for index {} (type: {}): {}", - index, type, error.getMessage()); + index, LogFields.text(type), LogFields.text(error.getMessage())); EnrichmentApiResponse errorResponse = EnrichmentApiResponse.builder() .success(false) .message("Enrichment failed: " + error.getMessage()) diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/event/JobEventPublisher.java b/modules/starter-data/src/main/java/org/fireflyframework/data/event/JobEventPublisher.java index b430b5b8c..49a6f83e4 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/event/JobEventPublisher.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/event/JobEventPublisher.java @@ -16,6 +16,8 @@ package org.fireflyframework.data.event; +import org.fireflyframework.kernel.logging.LogFields; + import org.fireflyframework.data.config.JobOrchestrationProperties; import org.fireflyframework.data.model.JobStage; import org.fireflyframework.data.model.JobStageRequest; @@ -65,7 +67,7 @@ public void publishJobStarted(JobStageRequest request) { ); publishEvent(event); - log.debug("Published job started event for execution: {}", request.getExecutionId()); + log.debug("Published job started event for execution: {}", LogFields.text(request.getExecutionId())); } /** @@ -89,7 +91,7 @@ public void publishJobStageCompleted(JobStage stage, JobStageResponse response) publishEvent(event); log.debug("Published job stage completed event for execution: {}, stage: {}, success: {}", - response.getExecutionId(), stage, response.isSuccess()); + LogFields.text(response.getExecutionId()), stage, response.isSuccess()); } /** @@ -117,7 +119,7 @@ public void publishJobFailed(String executionId, String jobType, String error, T ); publishEvent(event); - log.debug("Published job failed event for execution: {}", executionId); + log.debug("Published job failed event for execution: {}", LogFields.text(executionId)); } /** @@ -138,7 +140,7 @@ public void publishJobCompleted(String executionId, String jobType, Map published = new AtomicReference<>(); + ApplicationEventPublisher events = event -> published.set((JobEvent) event); + var properties = new JobOrchestrationProperties(); + properties.setPublishJobEvents(true); + var publisher = new JobEventPublisher(events, properties); + Map payload = Map.of("private", new Object()); + try (Capture logs = new Capture(JobEventPublisher.class)) { + publisher.publishJobStarted(JobStageRequest.builder().jobType(FIELD).executionId(FIELD).parameters(payload).build()); + assertEquals(FIELD, published.get().getExecutionId()); + assertSame(payload, published.get().getData()); + publisher.publishJobStageCompleted(JobStage.START, JobStageResponse.success(JobStage.START, FIELD, FIELD)); + assertEquals(FIELD, published.get().getExecutionId()); + var error = new IllegalArgumentException(FIELD); + publisher.publishJobFailed(FIELD, FIELD, FIELD, error); + assertEquals(FIELD, published.get().getData().get("exceptionMessage")); + publisher.publishJobCompleted(FIELD, FIELD, payload); + assertSame(payload, published.get().getData()); + logs.safe(); + assertEquals(4, logs.list.stream().filter(e -> e.getLevel() == Level.DEBUG).count()); + assertTrue(logs.text().contains(VISIBLE)); + } + } + + @Test void disabledJobPublicationDoesNotReadOrDispatchItsRequest() { + var properties = new JobOrchestrationProperties(); + properties.setPublishJobEvents(false); + var events = mock(ApplicationEventPublisher.class); + var publisher = new JobEventPublisher(events, properties); + publisher.publishJobStarted(null); + publisher.publishJobFailed(FIELD, FIELD, FIELD, null); + verifyNoInteractions(events); + } + + @Test void previewSanitizesDiagnosticsWithoutChangingMetadataOrSelection() { + var f = new Fixture(); + try (Capture logs = new Capture(SmartEnrichmentController.class)) { + var response = f.controller.previewEnrichment(f.request()).block().getBody(); + assertEquals(FIELD, response.getProviderName()); + assertEquals(FIELD, response.getEnrichmentType()); + verify(f.registry).getEnricherForTypeAndTenant(FIELD, TENANT); + logs.safe(); + assertTrue(logs.text().contains(VISIBLE)); + } + } + + @Test void missingPreviewPreservesNotFoundAndWarning() { + var f = new Fixture(); + when(f.registry.getEnricherForTypeAndTenant(FIELD, TENANT)).thenReturn(Optional.empty()); + try (Capture logs = new Capture(SmartEnrichmentController.class)) { + assertEquals(404, f.controller.previewEnrichment(f.request()).block().getStatusCode().value()); + logs.safe(); + assertTrue(logs.list.stream().anyMatch(e -> e.getLevel() == Level.WARN)); + } + } + + @Test void smartSuccessPreservesRawRequestResultAndCorrelation() { + var f = new Fixture(); + var result = f.response(true); + when(f.enricher.enrich(any())).thenReturn(Mono.just(result)); + var request = f.request(); + try (Capture logs = new Capture(SmartEnrichmentController.class)) { + MDC.put("correlationId", "unchanged"); + try { + var actual = f.controller.smartEnrich(request).block(); + assertSame(result.getEnrichedData(), actual.getEnrichedData()); + assertSame(result.getRawProviderResponse(), actual.getRawProviderResponse()); + assertEquals(FIELD, actual.getProviderName()); + assertEquals(FIELD, actual.getType()); + assertEquals(FIELD, actual.getRequestId()); + var captured = ArgumentCaptor.forClass(EnrichmentRequest.class); + verify(f.enricher).enrich(captured.capture()); + assertSame(request.getSource(), captured.getValue().getSourceDto()); + assertSame(request.getParams(), captured.getValue().getParameters()); + assertEquals(FIELD, captured.getValue().getRequestId()); + assertEquals("unchanged", MDC.get("correlationId")); + logs.safe(); + } finally { MDC.remove("correlationId"); } + } + } + + @Test void smartFailureResponseKeepsOriginalMessageAndWarnLevel() { + var f = new Fixture(); + when(f.enricher.enrich(any())).thenReturn(Mono.just(f.response(false))); + try (Capture logs = new Capture(SmartEnrichmentController.class)) { + assertEquals(FIELD, f.controller.smartEnrich(f.request()).block().getMessage()); + logs.safe(); + assertTrue(logs.list.stream().anyMatch(e -> e.getLevel() == Level.WARN)); + } + } + + @Test void missingSmartProviderKeepsExceptionMessageAndStatus() { + var f = new Fixture(); + when(f.registry.getEnricherForTypeAndTenant(FIELD, TENANT)).thenReturn(Optional.empty()); + try (Capture logs = new Capture(SmartEnrichmentController.class)) { + var error = assertThrows(ResponseStatusException.class, () -> f.controller.smartEnrich(f.request())); + assertEquals(404, error.getStatusCode().value()); + assertTrue(error.getReason().contains(FIELD)); + logs.safe(); + } + } + + @Test void smartErrorsKeepThrowableIdentityAndOnlyEscapeDiagnosticArguments() { + var f = new Fixture(); + var failure = new IllegalStateException(FIELD); + when(f.enricher.enrich(any())).thenReturn(Mono.error(failure)); + try (Capture logs = new Capture(SmartEnrichmentController.class)) { + assertSame(failure, assertThrows(IllegalStateException.class, () -> f.controller.smartEnrich(f.request()).block())); + var event = logs.list.stream().filter(e -> e.getLevel() == Level.ERROR).findFirst().orElseThrow(); + assertSame(failure, ((ThrowableProxy) event.getThrowableProxy()).getThrowable()); + logs.safe(); + } + } + + @Test void batchAndStreamKeepFailureBodiesAndContinuation() { + var f = new Fixture(); + var failure = new IllegalArgumentException(FIELD); + when(f.enricher.enrichBatch(anyList())).thenReturn(Flux.error(failure)); + when(f.enricher.enrich(any())).thenReturn(Mono.error(failure)); + try (Capture logs = new Capture(SmartEnrichmentController.class)) { + var batch = f.controller.smartEnrichBatch(List.of(f.request())).collectList().block(); + assertEquals(FIELD, batch.getFirst().getError()); + var stream = f.controller.streamEnrichment(List.of(f.request())).collectList().block(); + assertEquals("Enrichment failed: " + FIELD, stream.getFirst().data().getMessage()); + assertEquals("complete", stream.getLast().event()); + logs.safe(); + } + } + + @Test void ordinaryAndNullDiagnosticStringsKeepTheirRendering() { + var f = new Fixture(); + when(f.enricher.getProviderName()).thenReturn("ordinary-provider"); + when(f.registry.getEnricherForTypeAndTenant("ordinary", TENANT)).thenReturn(Optional.of(f.enricher)); + var result = f.response(true); + result.setProviderName(null); result.setType("ordinary"); result.setRequestId(null); + when(f.enricher.enrich(any())).thenReturn(Mono.just(result)); + try (Capture logs = new Capture(SmartEnrichmentController.class)) { + f.controller.smartEnrich(EnrichmentApiRequest.builder().type("ordinary").tenantId(TENANT).build()).block(); + assertTrue(logs.text().contains("Selected enricher: ordinary-provider")); + assertTrue(logs.text().contains("provider: null")); + assertTrue(logs.text().contains("requestId: null")); + logs.safe(); + } + } + + private static final class Fixture { + final DataEnricherRegistry registry = mock(DataEnricherRegistry.class); + final DataEnricher enricher = mock(DataEnricher.class); + final SmartEnrichmentController controller = new SmartEnrichmentController(registry, null); + Fixture() { + when(registry.getEnricherForTypeAndTenant(FIELD, TENANT)).thenReturn(Optional.of(enricher)); + when(enricher.getProviderName()).thenReturn(FIELD); + when(enricher.getTenantId()).thenReturn(TENANT); + } + EnrichmentApiRequest request() { + return EnrichmentApiRequest.builder().type(FIELD).tenantId(TENANT).requestId(FIELD) + .source(new Object()).params(Map.of("private", "unchanged")).build(); + } + EnrichmentResponse response(boolean success) { + return EnrichmentResponse.builder().success(success).type(FIELD).providerName(FIELD).message(FIELD) + .requestId(FIELD).enrichedData(new Object()).rawProviderResponse(new Object()).build(); + } + } + + private static final class Capture extends ListAppender implements AutoCloseable { + private final Logger logger; + private final Level original; + Capture(Class type) { + logger = (Logger) LoggerFactory.getLogger(type); original = logger.getLevel(); + logger.setLevel(Level.DEBUG); start(); logger.addAppender(this); + } + void safe() { + for (var event : list) for (char c : new char[]{'\r', '\n', '\u000b', '\f', '\u0085', '\u2028', '\u2029'}) + assertFalse(event.getFormattedMessage().indexOf(c) >= 0, event.getMessage()); + } + String text() { return list.stream().map(ILoggingEvent::getFormattedMessage).reduce("", (a, b) -> a + b); } + @Override public void close() { logger.detachAppender(this); logger.setLevel(original); stop(); } + } +} From e4a13ca5bfecd5e335497451fb895e88756df3b6 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 27 Oct 2026 00:58:20 -0700 Subject: [PATCH 34/44] Escape String diagnostics in global data controllers --- .../EnrichmentDiscoveryController.java | 5 +- .../GlobalEnrichmentHealthController.java | 7 +- .../GlobalOperationsController.java | 27 +-- .../GlobalControllerLogSafetyTest.java | 165 ++++++++++++++++++ 4 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 modules/starter-data/src/test/java/org/fireflyframework/data/logging/GlobalControllerLogSafetyTest.java diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/EnrichmentDiscoveryController.java b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/EnrichmentDiscoveryController.java index 39552c880..40495fd3d 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/EnrichmentDiscoveryController.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/EnrichmentDiscoveryController.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.controller; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.enrichment.EnricherMetadataReader; import org.fireflyframework.data.service.DataEnricher; import org.fireflyframework.data.service.DataEnricherRegistry; @@ -183,7 +184,7 @@ public Mono> listProviders( @RequestParam(required = false) UUID tenantId) { log.info("Received request to list providers" + - (type != null ? " for type: " + type : "") + + (type != null ? " for type: " + LogFields.text(type) : "") + (tenantId != null ? " for tenant: " + tenantId : "")); return Mono.fromSupplier(() -> { @@ -241,7 +242,7 @@ public Mono> listProviders( .collect(Collectors.toList()); log.info("Found {} enrichers" + - (type != null ? " for type: " + type : "") + + (type != null ? " for type: " + LogFields.text(type) : "") + (tenantId != null ? " for tenant: " + tenantId : ""), providers.size()); diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/GlobalEnrichmentHealthController.java b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/GlobalEnrichmentHealthController.java index 62984b3ee..a2ab9245d 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/GlobalEnrichmentHealthController.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/GlobalEnrichmentHealthController.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.controller; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.enrichment.EnricherMetadataReader; import org.fireflyframework.data.service.DataEnricher; import org.fireflyframework.data.service.DataEnricherRegistry; @@ -133,7 +134,7 @@ public Mono checkGlobalHealth( @Parameter(description = "Optional enrichment type to filter enrichers") @RequestParam(required = false) String type) { - log.info("Received global health check request - tenantId: {}, type: {}", tenantId, type); + log.info("Received global health check request - tenantId: {}, type: {}", tenantId, LogFields.text(type)); // Get all enrichers (or filtered by tenant/type) List> enrichers; @@ -148,7 +149,7 @@ public Mono checkGlobalHealth( } if (enrichers.isEmpty()) { - log.warn("No enrichers found for health check - tenantId: {}, type: {}", tenantId, type); + log.warn("No enrichers found for health check - tenantId: {}, type: {}", tenantId, LogFields.text(type)); return Mono.just(GlobalHealthResponse.builder() .overallHealthy(true) .totalEnrichers(0) @@ -179,7 +180,7 @@ public Mono checkGlobalHealth( }) .onErrorResume(error -> { log.error("Health check failed for enricher {}: {}", - enricher.getProviderName(), error.getMessage()); + LogFields.text(enricher.getProviderName()), LogFields.text(error.getMessage())); return Mono.just(EnricherHealthInfo.builder() .providerName(enricher.getProviderName()) diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/GlobalOperationsController.java b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/GlobalOperationsController.java index 84d150697..bbe08aa4d 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/GlobalOperationsController.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/GlobalOperationsController.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.controller; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.controller.dto.OperationCatalogResponse; import org.fireflyframework.data.enrichment.EnricherMetadataReader; import org.fireflyframework.data.operation.EnricherOperationInterface; @@ -166,7 +167,7 @@ public Mono>> listOperations( @Parameter(description = "Optional tenant ID to filter operations") @RequestParam(required = false) UUID tenantId) { - log.info("Received request to list operations - type: {}, tenantId: {}", type, tenantId); + log.info("Received request to list operations - type: {}, tenantId: {}", LogFields.text(type), tenantId); // Get enrichers (filtered or all) List> enrichers; @@ -271,9 +272,9 @@ public Mono> executeOperation( @RequestBody OperationExecutionRequest executionRequest) { log.info("Received operation execution request - type: {}, tenantId: {}, operationId: {}", - executionRequest.getType(), + LogFields.text(executionRequest.getType()), executionRequest.getTenantId(), - executionRequest.getOperationId()); + LogFields.text(executionRequest.getOperationId())); // Validate request if (executionRequest.getType() == null || executionRequest.getType().isBlank()) { @@ -296,7 +297,7 @@ public Mono> executeOperation( executionRequest.getType(), executionRequest.getTenantId() ); - log.warn(message); + log.warn(LogFields.text(message)); return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }); @@ -306,7 +307,7 @@ public Mono> executeOperation( "Enricher '%s' does not support operations", enricher.getProviderName() ); - log.warn(message); + log.warn(LogFields.text(message)); throw new ResponseStatusException(HttpStatus.NOT_FOUND, message); } @@ -323,13 +324,13 @@ public Mono> executeOperation( executionRequest.getOperationId(), enricher.getProviderName() ); - log.warn(message); + log.warn(LogFields.text(message)); return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }); log.info("Executing operation '{}' from provider '{}' for tenant '{}'", - executionRequest.getOperationId(), - enricher.getProviderName(), + LogFields.text(executionRequest.getOperationId()), + LogFields.text(enricher.getProviderName()), executionRequest.getTenantId()); // Execute operation @@ -337,16 +338,16 @@ public Mono> executeOperation( .map(ResponseEntity::ok) .doOnSuccess(response -> { log.info("Operation '{}' executed successfully for provider '{}', tenant '{}'", - executionRequest.getOperationId(), - enricher.getProviderName(), + LogFields.text(executionRequest.getOperationId()), + LogFields.text(enricher.getProviderName()), executionRequest.getTenantId()); }) .doOnError(error -> { log.error("Operation '{}' failed for provider '{}', tenant '{}': {}", - executionRequest.getOperationId(), - enricher.getProviderName(), + LogFields.text(executionRequest.getOperationId()), + LogFields.text(enricher.getProviderName()), executionRequest.getTenantId(), - error.getMessage(), + LogFields.text(error.getMessage()), error); }); } diff --git a/modules/starter-data/src/test/java/org/fireflyframework/data/logging/GlobalControllerLogSafetyTest.java b/modules/starter-data/src/test/java/org/fireflyframework/data/logging/GlobalControllerLogSafetyTest.java new file mode 100644 index 000000000..38e74fbcd --- /dev/null +++ b/modules/starter-data/src/test/java/org/fireflyframework/data/logging/GlobalControllerLogSafetyTest.java @@ -0,0 +1,165 @@ +/* Copyright 2026 Firefly Software Foundation. Licensed under the Apache License, Version 2.0. */ +package org.fireflyframework.data.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import org.fireflyframework.data.controller.EnrichmentDiscoveryController; +import org.fireflyframework.data.controller.GlobalEnrichmentHealthController; +import org.fireflyframework.data.controller.GlobalOperationsController; +import org.fireflyframework.data.operation.EnricherOperationInterface; +import org.fireflyframework.data.operation.EnricherOperationMetadata; +import org.fireflyframework.data.service.DataEnricher; +import org.fireflyframework.data.service.DataEnricherRegistry; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GlobalControllerLogSafetyTest { + private static final String FIELD = "field\r\nline\u000b\f\u0085\u2028\u2029"; + private static final UUID TENANT = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Test void discoveryEscapesDiagnosticsAndPreservesProviderMetadata() { + var f = new Fixture(); + when(f.enricher.supportsEnrichmentType(FIELD)).thenReturn(true); + try (Capture logs = new Capture(EnrichmentDiscoveryController.class)) { + var providers = new EnrichmentDiscoveryController(f.registry).listProviders(FIELD, TENANT).block(); + assertEquals(FIELD, providers.getFirst().providerName()); + assertEquals(FIELD, providers.getFirst().type()); + logs.safe(); + } + } + + @Test void ordinaryAndAbsentDiscoveryFiltersKeepTheirRendering() { + var f = new Fixture(); + try (Capture logs = new Capture(EnrichmentDiscoveryController.class)) { + assertEquals(1, new EnrichmentDiscoveryController(f.registry).listProviders(null, null).block().size()); + assertTrue(logs.text().contains("Received request to list providers")); + assertTrue(logs.text().contains("Found 1 enrichers")); + logs.safe(); + } + } + + @Test void emptyHealthCheckEscapesRequestedTypeAndPreservesEmptySuccess() { + var f = new Fixture(); + when(f.registry.getAllEnrichersForTypeAndTenant(FIELD, TENANT)).thenReturn(List.of()); + try (Capture logs = new Capture(GlobalEnrichmentHealthController.class)) { + var response = new GlobalEnrichmentHealthController(f.registry).checkGlobalHealth(TENANT, FIELD).block(); + assertTrue(response.overallHealthy()); + assertEquals(0, response.totalEnrichers()); + assertTrue(logs.list.stream().anyMatch(e -> e.getLevel() == Level.WARN)); + logs.safe(); + } + } + + @Test void failedHealthCheckPreservesOriginalFailureAndProvider() { + var f = new Fixture(); + when(f.enricher.isReady()).thenReturn(Mono.error(new IllegalStateException(FIELD))); + try (Capture logs = new Capture(GlobalEnrichmentHealthController.class)) { + var response = new GlobalEnrichmentHealthController(f.registry).checkGlobalHealth(TENANT, FIELD).block(); + assertFalse(response.overallHealthy()); + assertEquals(FIELD, response.enrichers().getFirst().providerName()); + assertEquals("Health check failed: " + FIELD, response.enrichers().getFirst().message()); + logs.safe(); + } + } + + @Test void operationCatalogPreservesOriginalProviderAndOperation() { + var f = new Fixture(); + try (Capture logs = new Capture(GlobalOperationsController.class)) { + var response = f.controller.listOperations(FIELD, TENANT).block().getBody().getFirst(); + assertEquals(FIELD, response.getProviderName()); + assertEquals(FIELD, response.getOperations().getFirst().getOperationId()); + logs.safe(); + } + } + + @Test void operationSuccessPreservesRequestAndResponseIdentity() { + var f = new Fixture(); + Object body = new Object(); + when(f.operation.execute(body)).thenReturn(Mono.just(body)); + var request = f.request(body); + try (Capture logs = new Capture(GlobalOperationsController.class)) { + assertSame(body, f.controller.executeOperation(request).block().getBody()); + assertEquals(FIELD, request.getType()); + assertEquals(FIELD, request.getOperationId()); + logs.safe(); + } + } + + @Test void operationFailurePreservesOriginalThrowable() { + var f = new Fixture(); + Object body = new Object(); + var failure = new IllegalStateException(FIELD); + when(f.operation.execute(body)).thenReturn(Mono.error(failure)); + try (Capture logs = new Capture(GlobalOperationsController.class)) { + assertSame(failure, assertThrows(IllegalStateException.class, () -> f.controller.executeOperation(f.request(body)).block())); + var event = logs.list.stream().filter(e -> e.getLevel() == Level.ERROR).findFirst().orElseThrow(); + assertSame(failure, ((ThrowableProxy) event.getThrowableProxy()).getThrowable()); + logs.safe(); + } + } + + @Test void missingProviderPreservesNotFoundReason() { + var f = new Fixture(); + when(f.registry.getEnricherForTypeAndTenant(FIELD, TENANT)).thenReturn(Optional.empty()); + try (Capture logs = new Capture(GlobalOperationsController.class)) { + var failure = assertThrows(ResponseStatusException.class, () -> f.controller.executeOperation(f.request(new Object()))); + assertEquals(404, failure.getStatusCode().value()); + assertTrue(failure.getReason().contains(FIELD)); + logs.safe(); + } + } + + private static final class Fixture { + final DataEnricherRegistry registry = mock(DataEnricherRegistry.class); + final DataEnricher enricher = mock(DataEnricher.class); + final EnricherOperationInterface operation = mock(EnricherOperationInterface.class); + final GlobalOperationsController controller = new GlobalOperationsController(registry); + Fixture() { + when(registry.getAllEnrichers()).thenReturn(List.of(enricher)); + when(registry.getAllEnrichersForTypeAndTenant(FIELD, TENANT)).thenReturn(List.of(enricher)); + when(registry.getEnricherForTypeAndTenant(FIELD, TENANT)).thenReturn(Optional.of(enricher)); + when(enricher.getProviderName()).thenReturn(FIELD); + when(enricher.getTenantId()).thenReturn(TENANT); + when(enricher.getSupportedEnrichmentTypes()).thenReturn(List.of(FIELD)); + doReturn(List.of(operation)).when(enricher).getOperations(); + when(operation.getMetadata()).thenReturn(EnricherOperationMetadata.builder().operationId(FIELD) + .method(RequestMethod.POST).tags(new String[0]).build()); + when(operation.getRequestType()).thenReturn(Object.class); + when(operation.getResponseType()).thenReturn(Object.class); + } + GlobalOperationsController.OperationExecutionRequest request(Object body) { + var request = new GlobalOperationsController.OperationExecutionRequest(); + request.setType(FIELD); request.setTenantId(TENANT); request.setOperationId(FIELD); request.setRequest(body); + return request; + } + } + + private static final class Capture extends ListAppender implements AutoCloseable { + private final Logger logger; + private final Level original; + Capture(Class type) { + logger = (Logger) LoggerFactory.getLogger(type); original = logger.getLevel(); + logger.setLevel(Level.DEBUG); start(); logger.addAppender(this); + } + void safe() { + assertFalse(list.isEmpty()); + for (var event : list) for (char c : new char[]{'\r', '\n', '\u000b', '\f', '\u0085', '\u2028', '\u2029'}) + assertFalse(event.getFormattedMessage().indexOf(c) >= 0, event.getMessage()); + } + String text() { return list.stream().map(ILoggingEvent::getFormattedMessage).reduce("", (a, b) -> a + b); } + @Override public void close() { logger.detachAppender(this); logger.setLevel(original); stop(); } + } +} From 7665c0a91c007204f9121a1c6712a75bb1974def Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 27 Oct 2026 03:13:54 -0700 Subject: [PATCH 35/44] Escape remaining starter-data diagnostic fields --- .../AbstractSyncDataJobController.java | 37 +-- .../data/event/EnrichmentEventPublisher.java | 9 +- .../data/observability/JobMetricsService.java | 19 +- .../service/JobExecutionResultService.java | 27 +-- .../logging/RemainingDataLogSafetyTest.java | 216 ++++++++++++++++++ 5 files changed, 264 insertions(+), 44 deletions(-) create mode 100644 modules/starter-data/src/test/java/org/fireflyframework/data/logging/RemainingDataLogSafetyTest.java diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/AbstractSyncDataJobController.java b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/AbstractSyncDataJobController.java index 3f9fedef9..1856a859f 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/controller/AbstractSyncDataJobController.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/controller/AbstractSyncDataJobController.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.controller; +import org.fireflyframework.kernel.logging.LogFields; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.fireflyframework.data.model.JobStageRequest; @@ -119,13 +120,13 @@ public Mono execute(Map parameters, String requestId, String initiator, String metadata) { - log.info("Received synchronous job execution request - jobName: {}, parameters: {}, requestId: {}, initiator: {}", - syncDataJobService.getJobName(), - parameters != null ? parameters.keySet() : "none", - requestId, - initiator); - log.debug("Full synchronous job request - jobName: {}, parameters: {}, metadata: {}", - syncDataJobService.getJobName(), parameters, metadata); + log.info("Received synchronous job execution request - jobName: {}, parametersPresent: {}, requestId: {}, initiator: {}", + LogFields.text(syncDataJobService.getJobName()), + parameters != null, + LogFields.text(requestId), + LogFields.text(initiator)); + log.debug("Synchronous job request diagnostics - jobName: {}, parametersPresent: {}, metadataPresent: {}", + LogFields.text(syncDataJobService.getJobName()), parameters != null, metadata != null); // Parse metadata if provided Map metadataMap = parseMetadata(metadata); @@ -141,22 +142,22 @@ public Mono execute(Map parameters, return syncDataJobService.execute(request) .doOnSuccess(response -> { if (response.isSuccess()) { - log.info("Synchronous job completed successfully - jobName: {}, executionId: {}, dataKeys: {}", - syncDataJobService.getJobName(), - response.getExecutionId(), - response.getData() != null ? response.getData().keySet() : "none"); + log.info("Synchronous job completed successfully - jobName: {}, executionId: {}, dataPresent: {}", + LogFields.text(syncDataJobService.getJobName()), + LogFields.text(response.getExecutionId()), + response.getData() != null); } else { log.warn("Synchronous job completed with failure - jobName: {}, executionId: {}, message: {}", - syncDataJobService.getJobName(), - response.getExecutionId(), - response.getMessage()); + LogFields.text(syncDataJobService.getJobName()), + LogFields.text(response.getExecutionId()), + LogFields.text(response.getMessage())); } - log.debug("Full synchronous job response - jobName: {}, executionId: {}, response: {}", - syncDataJobService.getJobName(), response.getExecutionId(), response); + log.debug("Synchronous job response diagnostics - jobName: {}, executionId: {}, responseType: {}", + LogFields.text(syncDataJobService.getJobName()), LogFields.text(response.getExecutionId()), LogFields.text(response.getClass().getName())); }) .doOnError(error -> { log.error("Synchronous job failed - jobName: {}, error: {}", - syncDataJobService.getJobName(), error.getMessage(), error); + LogFields.text(syncDataJobService.getJobName()), LogFields.text(error.getMessage()), error); }); } @@ -176,7 +177,7 @@ private Map parseMetadata(String metadata) { Map metadataMap = objectMapper.readValue(metadata, Map.class); return metadataMap; } catch (JsonProcessingException e) { - log.warn("Failed to parse metadata JSON, using empty map: {}", e.getMessage()); + log.warn("Failed to parse metadata JSON, using empty map - exceptionType: {}", LogFields.text(e.getClass().getSimpleName())); return new HashMap<>(); } } diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/event/EnrichmentEventPublisher.java b/modules/starter-data/src/main/java/org/fireflyframework/data/event/EnrichmentEventPublisher.java index 3eb5ca7c3..afcf182d4 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/event/EnrichmentEventPublisher.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/event/EnrichmentEventPublisher.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.event; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.model.EnrichmentRequest; import org.fireflyframework.data.model.EnrichmentResponse; import lombok.extern.slf4j.Slf4j; @@ -58,7 +59,7 @@ public void publishEnrichmentStarted(EnrichmentRequest request, String providerN publishEvent(event); log.debug("Published enrichment started event: type={}, provider={}, requestId={}", - request.getType(), providerName, request.getRequestId()); + LogFields.text(request.getType()), LogFields.text(providerName), LogFields.text(request.getRequestId())); } /** @@ -84,7 +85,7 @@ public void publishEnrichmentCompleted(EnrichmentRequest request, publishEvent(event); log.debug("Published enrichment completed event: type={}, provider={}, success={}, duration={}ms", - response.getType(), response.getProviderName(), + LogFields.text(response.getType()), LogFields.text(response.getProviderName()), response.isSuccess(), durationMillis); } @@ -121,7 +122,7 @@ public void publishEnrichmentFailed(EnrichmentRequest request, publishEvent(event); log.debug("Published enrichment failed event: type={}, provider={}, error={}", - request.getType(), providerName, error); + LogFields.text(request.getType()), LogFields.text(providerName), LogFields.text(error)); } /** @@ -131,7 +132,7 @@ private void publishEvent(EnrichmentEvent event) { try { eventPublisher.publishEvent(event); } catch (Exception e) { - log.warn("Failed to publish enrichment event: {}", e.getMessage(), e); + log.warn("Failed to publish enrichment event: {}", LogFields.text(e.getMessage()), e); } } } diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/observability/JobMetricsService.java b/modules/starter-data/src/main/java/org/fireflyframework/data/observability/JobMetricsService.java index 890e2a6b0..7f6ef48e7 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/observability/JobMetricsService.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/observability/JobMetricsService.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.observability; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.config.JobOrchestrationProperties; import org.fireflyframework.data.model.JobStage; import org.fireflyframework.data.orchestration.model.JobExecutionStatus; @@ -59,7 +60,7 @@ public void recordJobStageExecution(JobStage stage, String status, Duration dura "orchestrator", properties.getOrchestratorType()) .record(duration); - log.debug("Recorded metric for stage {} with status {} and duration {}", stage, status, duration); + log.debug("Recorded metric for stage {} with status {} and duration {}", stage, LogFields.text(status), duration); } /** @@ -76,7 +77,7 @@ public void incrementJobStageCounter(JobStage stage, String status) { "orchestrator", properties.getOrchestratorType()) .increment(); - log.debug("Incremented counter for stage {} with status {}", stage, status); + log.debug("Incremented counter for stage {} with status {}", stage, LogFields.text(status)); } /** @@ -93,7 +94,7 @@ public void recordJobExecutionStart(String executionId) { "orchestrator", properties.getOrchestratorType()) .increment(); - log.debug("Recorded job execution start for {}", executionId); + log.debug("Recorded job execution start for {}", LogFields.text(executionId)); } /** @@ -114,7 +115,7 @@ public void recordJobExecutionCompletion(String executionId, JobExecutionStatus .record(duration); log.debug("Recorded job execution completion for {} with status {} and duration {}", - executionId, status, duration); + LogFields.text(executionId), status, duration); } counter("execution.completed", @@ -137,7 +138,7 @@ public void recordJobError(JobStage stage, String errorType) { "orchestrator", properties.getOrchestratorType()) .increment(); - log.debug("Recorded error for stage {} with type {}", stage, errorType); + log.debug("Recorded error for stage {} with type {}", stage, LogFields.text(errorType)); } /** @@ -154,7 +155,7 @@ public void recordMapperExecution(String mapperName, boolean success, Duration d .record(duration); log.debug("Recorded mapper execution for {} with status {} and duration {}", - mapperName, success ? "success" : "failure", duration); + LogFields.text(mapperName), success ? "success" : "failure", duration); } /** @@ -172,7 +173,7 @@ public void recordOrchestratorOperation(String operation, boolean success, Durat .record(duration); log.debug("Recorded orchestrator operation {} with status {} and duration {}", - operation, success ? "success" : "failure", duration); + LogFields.text(operation), success ? "success" : "failure", duration); } /** @@ -237,7 +238,7 @@ public void recordEnrichmentMetrics(String enrichmentType, } log.debug("Recorded enrichment metrics: type={}, provider={}, success={}, duration={}ms", - enrichmentType, providerName, success, durationMillis); + LogFields.text(enrichmentType), LogFields.text(providerName), success, durationMillis); } /** @@ -263,7 +264,7 @@ public void recordEnrichmentError(String enrichmentType, .increment(); log.debug("Recorded enrichment error: type={}, provider={}, errorType={}", - enrichmentType, providerName, errorType); + LogFields.text(enrichmentType), LogFields.text(providerName), LogFields.text(errorType)); } private boolean isMetricsEnabled() { diff --git a/modules/starter-data/src/main/java/org/fireflyframework/data/persistence/service/JobExecutionResultService.java b/modules/starter-data/src/main/java/org/fireflyframework/data/persistence/service/JobExecutionResultService.java index ffb29cf1b..7a4e3755d 100644 --- a/modules/starter-data/src/main/java/org/fireflyframework/data/persistence/service/JobExecutionResultService.java +++ b/modules/starter-data/src/main/java/org/fireflyframework/data/persistence/service/JobExecutionResultService.java @@ -16,6 +16,7 @@ package org.fireflyframework.data.persistence.service; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.data.config.JobOrchestrationProperties; import org.fireflyframework.data.model.JobStageRequest; import org.fireflyframework.data.model.JobStageResponse; @@ -89,8 +90,8 @@ public Mono saveSuccessResult( enrichWithContext(result, request, orchestratorType, jobDefinition); return resultRepository.get().save(result) - .doOnSuccess(saved -> log.info("Saved successful job execution result: {}", saved.getResultId())) - .doOnError(error -> log.error("Failed to save job execution result: {}", error.getMessage(), error)) + .doOnSuccess(saved -> log.info("Saved successful job execution result: {}", LogFields.text(saved.getResultId()))) + .doOnError(error -> log.error("Failed to save job execution result: {}", LogFields.text(error.getMessage()), error)) .onErrorResume(error -> Mono.empty()); // Don't fail the operation if save fails } @@ -122,8 +123,8 @@ public Mono saveFailureResult( enrichWithContext(result, request, orchestratorType, jobDefinition); return resultRepository.get().save(result) - .doOnSuccess(saved -> log.info("Saved failed job execution result: {}", saved.getResultId())) - .doOnError(error -> log.error("Failed to save job execution result: {}", error.getMessage(), error)) + .doOnSuccess(saved -> log.info("Saved failed job execution result: {}", LogFields.text(saved.getResultId()))) + .doOnError(error -> log.error("Failed to save job execution result: {}", LogFields.text(error.getMessage()), error)) .onErrorResume(error -> Mono.empty()); } @@ -147,8 +148,8 @@ public Mono updateWithTransformedOutput( result.setMapperName(mapperName); return resultRepository.get().save(result); }) - .doOnSuccess(saved -> log.debug("Updated result with transformed output: {}", saved.getResultId())) - .doOnError(error -> log.error("Failed to update result: {}", error.getMessage(), error)); + .doOnSuccess(saved -> log.debug("Updated result with transformed output: {}", LogFields.text(saved.getResultId()))) + .doOnError(error -> log.error("Failed to update result: {}", LogFields.text(error.getMessage()), error)); } /** @@ -205,7 +206,7 @@ public Mono> getCachedResult(String executionId) { .defaultIfEmpty(Optional.empty()) .doOnNext(result -> { if (result.isPresent()) { - log.debug("Found valid cached result for execution: {}", executionId); + log.debug("Found valid cached result for execution: {}", LogFields.text(executionId)); } }); } @@ -223,7 +224,7 @@ public Mono cleanupOldResults() { return resultRepository.get().deleteByEndTimeBefore(cutoffTime) .doOnSuccess(count -> log.info("Cleaned up {} old job execution results", count)) - .doOnError(error -> log.error("Failed to cleanup old results: {}", error.getMessage(), error)); + .doOnError(error -> log.error("Failed to cleanup old results: {}", LogFields.text(error.getMessage()), error)); } /** @@ -236,7 +237,7 @@ public Mono cleanupExpiredCachedResults() { return resultRepository.get().deleteExpired() .doOnSuccess(count -> log.info("Cleaned up {} expired cached results", count)) - .doOnError(error -> log.error("Failed to cleanup expired results: {}", error.getMessage(), error)); + .doOnError(error -> log.error("Failed to cleanup expired results: {}", LogFields.text(error.getMessage()), error)); } /** @@ -287,7 +288,7 @@ private void enrichWithContext( private String extractTraceId(io.micrometer.observation.Observation observation) { String traceId = TracingContextExtractor.extractTraceId(observation); if (traceId != null) { - log.trace("Extracted trace ID: {}", traceId); + log.trace("Extracted trace ID: {}", LogFields.text(traceId)); } else { log.trace("No trace ID available in observation context"); } @@ -304,7 +305,7 @@ private String extractTraceId(io.micrometer.observation.Observation observation) private String extractSpanId(io.micrometer.observation.Observation observation) { String spanId = TracingContextExtractor.extractSpanId(observation); if (spanId != null) { - log.trace("Extracted span ID: {}", spanId); + log.trace("Extracted span ID: {}", LogFields.text(spanId)); } else { log.trace("No span ID available in observation context"); } @@ -326,9 +327,9 @@ private Long estimateDataSize(JobExecutionResult result) { if (totalSize > 0) { log.debug("Calculated data size for execution {}: {} ({})", - result.getExecutionId(), + LogFields.text(result.getExecutionId()), totalSize, - DataSizeCalculator.formatSize(totalSize)); + LogFields.text(DataSizeCalculator.formatSize(totalSize))); } return totalSize; diff --git a/modules/starter-data/src/test/java/org/fireflyframework/data/logging/RemainingDataLogSafetyTest.java b/modules/starter-data/src/test/java/org/fireflyframework/data/logging/RemainingDataLogSafetyTest.java new file mode 100644 index 000000000..4141f4308 --- /dev/null +++ b/modules/starter-data/src/test/java/org/fireflyframework/data/logging/RemainingDataLogSafetyTest.java @@ -0,0 +1,216 @@ +/* Copyright 2026 Firefly Software Foundation. Licensed under the Apache License, Version 2.0. */ +package org.fireflyframework.data.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.ObservationRegistry; +import org.fireflyframework.data.config.JobOrchestrationProperties; +import org.fireflyframework.data.controller.AbstractSyncDataJobController; +import org.fireflyframework.data.event.*; +import org.fireflyframework.data.model.*; +import org.fireflyframework.data.observability.JobMetricsService; +import org.fireflyframework.data.orchestration.model.JobExecutionStatus; +import org.fireflyframework.data.persistence.model.JobExecutionResult; +import org.fireflyframework.data.persistence.port.JobExecutionResultRepository; +import org.fireflyframework.data.persistence.service.JobExecutionResultService; +import org.fireflyframework.data.service.SyncDataJobService; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.context.ApplicationEventPublisher; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class RemainingDataLogSafetyTest { + private static final String FIELD = "field\r\nline\u000b\f\u0085\u2028\u2029"; + + @Test void enrichmentMetricDiagnosticsPreserveExactRawTags() { + var properties = new JobOrchestrationProperties(); + var registry = new SimpleMeterRegistry(); + try (var logs = new Capture(JobMetricsService.class)) { + var service = new JobMetricsService(registry, properties); + service.recordEnrichmentMetrics(FIELD, FIELD, true, 42, 3, 1.5); + service.recordEnrichmentError(FIELD, FIELD, FIELD, 42); + assertNotNull(registry.find("firefly.data.enrichment.duration").tags("type", FIELD, "provider", FIELD).timer()); + assertEquals(1, registry.find("firefly.data.enrichment.errors").tags("type", FIELD, "provider", FIELD, "error.type", FIELD).counter().count()); + logs.safe(); + } finally { registry.close(); } + } + + @Test void ordinaryJobMetricsKeepRawExecutionKeysAndStatuses() { + var properties = new JobOrchestrationProperties(); + var registry = new SimpleMeterRegistry(); + try (var logs = new Capture(JobMetricsService.class)) { + var service = new JobMetricsService(registry, properties); + service.recordJobStageExecution(JobStage.START, FIELD, Duration.ofMillis(2)); + service.incrementJobStageCounter(JobStage.CHECK, FIELD); + service.recordJobExecutionStart(FIELD); + service.recordJobExecutionCompletion(FIELD, JobExecutionStatus.SUCCEEDED); + service.recordJobError(JobStage.CHECK, FIELD); + service.recordMapperExecution(FIELD, true, Duration.ofMillis(2)); + service.recordOrchestratorOperation(FIELD, false, Duration.ofMillis(2)); + assertNotNull(registry.find("firefly.data.execution.duration").timer()); + assertEquals(FIELD, registry.find("firefly.data.stage.execution").timer().getId().getTag("status")); + logs.safe(); + } finally { registry.close(); } + } + + @Test void enrichmentEventsRetainRawFieldsAndMetadata() { + var events = new ArrayList(); + ApplicationEventPublisher publisher = event -> events.add((EnrichmentEvent) event); + var request = EnrichmentRequest.builder().type(FIELD).requestId(FIELD).metadata(Map.of("raw", FIELD)).build(); + var response = EnrichmentResponse.builder().type(FIELD).providerName(FIELD).requestId(FIELD).success(true).message(FIELD).metadata(Map.of("raw", FIELD)).build(); + try (var logs = new Capture(EnrichmentEventPublisher.class)) { + var service = new EnrichmentEventPublisher(publisher); + service.publishEnrichmentStarted(request, FIELD); + service.publishEnrichmentCompleted(request, response, 42); + service.publishEnrichmentFailed(request, FIELD, FIELD, new IllegalStateException(FIELD), 42); + assertEquals(3, events.size()); + for (var event : events) { assertEquals(FIELD, event.getEnrichmentType()); assertEquals(FIELD, event.getProviderName()); assertEquals(FIELD, event.getMetadata().get("raw")); } + assertEquals(FIELD, events.get(2).getError()); + assertEquals(FIELD, events.get(2).getMetadata().get("exceptionMessage")); + logs.safe(); + } + } + + @Test void eventPublicationFailureRetainsOriginalThrowable() { + var failure = new IllegalStateException(FIELD); + ApplicationEventPublisher publisher = event -> { throw failure; }; + try (var logs = new Capture(EnrichmentEventPublisher.class)) { + new EnrichmentEventPublisher(publisher).publishEnrichmentStarted(EnrichmentRequest.builder().type(FIELD).build(), FIELD); + logs.throwable(failure, Level.WARN); logs.safe(); + } + } + + @Test void persistenceSizeDiagnosticsPreserveRawDataAndExecutionIdentity() { + var repository = mock(JobExecutionResultRepository.class); + when(repository.save(any())).thenAnswer(call -> { JobExecutionResult row = call.getArgument(0); row.setResultId(FIELD); return Mono.just(row); }); + var request = JobStageRequest.builder().executionId(FIELD).requestId(FIELD).jobType("job").build(); + Map raw = Map.of("raw", "RAW_BODY_ONLY"); + try (var logs = new Capture(JobExecutionResultService.class)) { + var result = resultService(repository).saveSuccessResult(request, JobStageResponse.builder().success(true).build(), Instant.now(), raw, raw, "kind", "definition").block(); + assertEquals(FIELD, result.getExecutionId()); assertEquals(FIELD, result.getResultId()); assertSame(raw, result.getRawOutput()); + assertTrue(result.getDataSizeBytes() > 0); logs.safe(); + } + } + + @Test void persistedFailureTextAndRepositoryErrorIdentityRemainUnmodified() { + var repository = mock(JobExecutionResultRepository.class); + when(repository.save(any())).thenAnswer(call -> Mono.just(call.getArgument(0))); + var request = JobStageRequest.builder().executionId(FIELD).jobType("job").build(); + try (var logs = new Capture(JobExecutionResultService.class)) { + var result = resultService(repository).saveFailureResult(request, Instant.now(), FIELD, FIELD, "kind", "definition").block(); + assertEquals(FIELD, result.getErrorMessage()); assertEquals(FIELD, result.getErrorCause()); + var failure = new IllegalStateException(FIELD); + doReturn(Mono.error(failure)).when(repository).save(any()); + assertNull(resultService(repository).saveFailureResult(request, Instant.now(), FIELD, FIELD, "kind", "definition").block()); + logs.throwable(failure, Level.ERROR); logs.safe(); + } + } + + @Test void persistenceUpdateCacheAndCleanupKeepOriginalInputsAndCounts() { + var repository = mock(JobExecutionResultRepository.class); + var row = JobExecutionResult.builder().resultId(FIELD).executionId(FIELD).cacheable(true).expiresAt(Instant.now().plusSeconds(60)).build(); + when(repository.findByExecutionId(FIELD)).thenReturn(Mono.just(row)); + when(repository.save(any())).thenAnswer(call -> Mono.just(call.getArgument(0))); + when(repository.deleteByEndTimeBefore(any())).thenReturn(Mono.just(4L)); + when(repository.deleteExpired()).thenReturn(Mono.just(5L)); + try (var logs = new Capture(JobExecutionResultService.class)) { + var service = resultService(repository); Map output = Map.of("raw", FIELD); + assertSame(row, service.updateWithTransformedOutput(FIELD, output, FIELD, FIELD).block()); + assertSame(output, row.getTransformedOutput()); assertSame(row, service.getCachedResult(FIELD).block().orElseThrow()); + assertEquals(4L, service.cleanupOldResults().block()); assertEquals(5L, service.cleanupExpiredCachedResults().block()); + verify(repository, times(2)).findByExecutionId(FIELD); logs.safe(); + } + } + + @Test void syncRequestAndResponseDoNotTraverseOpaqueMaps() { + var parameters = new OpaqueMap(); var data = new OpaqueMap(); + var response = JobStageResponse.builder().success(true).executionId(FIELD).data(data).build(); + var service = mock(SyncDataJobService.class); when(service.getJobName()).thenReturn(FIELD); when(service.execute(any())).thenReturn(Mono.just(response)); + try (var logs = new Capture(AbstractSyncDataJobController.class)) { + assertSame(response, new Controller(service).execute(parameters, FIELD, FIELD, "{\"raw\":\"RAW_METADATA_ONLY\"}").block()); + var request = org.mockito.ArgumentCaptor.forClass(JobStageRequest.class); verify(service).execute(request.capture()); + assertSame(parameters, request.getValue().getParameters()); assertEquals("RAW_METADATA_ONLY", request.getValue().getMetadata().get("raw")); + assertFalse(logs.text().contains("RAW_METADATA_ONLY")); logs.safe(); + } + } + + @Test void syncResponseDiagnosticsNeverFormatTheResponseObject() { + var formatted = new AtomicInteger(); + var response = new JobStageResponse() { @Override public String toString() { formatted.incrementAndGet(); return "RAW_RESPONSE_ONLY"; } }; + response.setSuccess(true); response.setExecutionId("execution"); + var service = mock(SyncDataJobService.class); when(service.getJobName()).thenReturn("job"); when(service.execute(any())).thenReturn(Mono.just(response)); + try (var logs = new Capture(AbstractSyncDataJobController.class)) { + assertSame(response, new Controller(service).execute(null, null, null, null).block()); + logs.text(); assertEquals(0, formatted.get()); assertFalse(logs.text().contains("RAW_RESPONSE_ONLY")); + } + } + + @Test void invalidMetadataKeepsEmptyFallbackAndOnlyLogsExceptionType() { + var mapper = new ObjectMapper() { @Override public T readValue(String text, Class type) throws JsonProcessingException { throw new JsonParseException(null, "RAW_INVALID_METADATA_ONLY"); } }; + var service = mock(SyncDataJobService.class); when(service.getJobName()).thenReturn("job"); when(service.execute(any())).thenReturn(Mono.just(JobStageResponse.builder().success(true).build())); + try (var logs = new Capture(AbstractSyncDataJobController.class)) { + new Controller(service, mapper).execute(null, null, null, "ordinary-invalid-metadata").block(); + var request = org.mockito.ArgumentCaptor.forClass(JobStageRequest.class); verify(service).execute(request.capture()); + assertEquals(Map.of(), request.getValue().getMetadata()); assertFalse(logs.text().contains("RAW_INVALID_METADATA_ONLY")); + assertTrue(logs.text().contains("JsonParseException")); logs.safe(); + } + } + + @Test void syncFailureAndErrorPreserveResponseThrowableAndMdc() { + var service = mock(SyncDataJobService.class); when(service.getJobName()).thenReturn(FIELD); + var response = JobStageResponse.builder().success(false).executionId(FIELD).message(FIELD).build(); when(service.execute(any())).thenReturn(Mono.just(response)); + MDC.put("ordinary-context", FIELD); + try (var logs = new Capture(AbstractSyncDataJobController.class)) { + var controller = new Controller(service); assertSame(response, controller.execute(null, FIELD, FIELD, null).block()); + var failure = new IllegalStateException(FIELD); when(service.execute(any())).thenReturn(Mono.error(failure)); + assertSame(failure, assertThrows(IllegalStateException.class, () -> controller.execute(null, FIELD, FIELD, null).block())); + assertEquals(FIELD, MDC.get("ordinary-context")); logs.throwable(failure, Level.ERROR); logs.safe(); + } finally { MDC.remove("ordinary-context"); } + } + + @Test void ordinaryAndNullStringsKeepTheirDiagnosticRendering() { + var service = mock(SyncDataJobService.class); when(service.getJobName()).thenReturn("ordinary-job"); + var response = JobStageResponse.builder().success(false).executionId("ordinary-execution").message(null).build(); when(service.execute(any())).thenReturn(Mono.just(response)); + try (var logs = new Capture(AbstractSyncDataJobController.class)) { + assertSame(response, new Controller(service).execute(null, "ordinary-request", null, null).block()); + assertTrue(logs.text().contains("jobName: ordinary-job")); assertTrue(logs.text().contains("requestId: ordinary-request")); assertTrue(logs.text().contains("initiator: null")); + assertTrue(logs.text().contains("message: null")); logs.safe(); + } + } + + private static JobExecutionResultService resultService(JobExecutionResultRepository repository) { return new JobExecutionResultService(Optional.of(repository), new JobOrchestrationProperties(), ObservationRegistry.NOOP); } + private static final class Controller extends AbstractSyncDataJobController { + Controller(SyncDataJobService service) { super(service); } + Controller(SyncDataJobService service, ObjectMapper mapper) { super(service, mapper); } + } + private static final class OpaqueMap extends HashMap { + @Override public Set keySet() { throw new AssertionError("Logging must not traverse payload keys"); } + @Override public Set> entrySet() { throw new AssertionError("Logging must not traverse payload entries"); } + @Override public String toString() { throw new AssertionError("Logging must not format payload maps"); } + } + private static final class Capture extends ListAppender implements AutoCloseable { + final Logger logger; final Level original; + Capture(Class type) { logger=(Logger) LoggerFactory.getLogger(type); original=logger.getLevel(); logger.setLevel(Level.TRACE); start();logger.addAppender(this); } + void safe() { assertFalse(list.isEmpty()); for(var e:list) for(char c:new char[]{'\r','\n','\u000b','\f','\u0085','\u2028','\u2029'})assertFalse(e.getFormattedMessage().indexOf(c)>=0,e.getMessage()); } + String text() { return list.stream().map(ILoggingEvent::getFormattedMessage).reduce("",(a,b)->a+b); } + void throwable(Throwable failure,Level level) { var event=list.stream().filter(e->e.getLevel()==level&&e.getThrowableProxy()!=null).findFirst().orElseThrow();assertSame(failure,((ThrowableProxy)event.getThrowableProxy()).getThrowable()); } + @Override public void close(){logger.detachAppender(this);logger.setLevel(original);stop();} + } +} From a1bb5bdb649489980a0f5f0d9ad3a4b0d4b03c96 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 27 Oct 2026 06:31:23 -0700 Subject: [PATCH 36/44] Escape orchestration diagnostic fields without changing workflow behavior --- .../redis/RedisPersistenceProvider.java | 11 +- .../workflow/child/ChildWorkflowService.java | 5 +- .../workflow/engine/WorkflowEngine.java | 19 +- .../workflow/engine/WorkflowExecutor.java | 11 +- .../workflow/signal/SignalService.java | 9 +- .../workflow/timer/TimerService.java | 7 +- .../OrchestrationRuntimeLogSafetyTest.java | 234 ++++++++++++++++++ 7 files changed, 268 insertions(+), 28 deletions(-) create mode 100644 modules/orchestration/src/test/java/org/fireflyframework/orchestration/logging/OrchestrationRuntimeLogSafetyTest.java diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/redis/RedisPersistenceProvider.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/redis/RedisPersistenceProvider.java index c5fac9855..2b81d445a 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/redis/RedisPersistenceProvider.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/redis/RedisPersistenceProvider.java @@ -22,6 +22,7 @@ import org.fireflyframework.orchestration.core.model.ExecutionStatus; import org.fireflyframework.orchestration.core.persistence.ExecutionPersistenceProvider; import org.fireflyframework.orchestration.core.persistence.ExecutionState; +import org.fireflyframework.kernel.logging.LogFields; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.ScanOptions; @@ -62,7 +63,7 @@ public RedisPersistenceProvider(ReactiveRedisTemplate redisTempl this.stateKeyPrefix = (keyPrefix != null ? keyPrefix : "orchestration:") + "state:"; this.metaKeyPrefix = (keyPrefix != null ? keyPrefix : "orchestration:") + "meta:"; this.keyTtl = keyTtl; - log.info("[orchestration] RedisPersistenceProvider initialized with prefix: {}", keyPrefix); + log.info("[orchestration] RedisPersistenceProvider initialized with prefix: {}", LogFields.text(keyPrefix)); } public RedisPersistenceProvider(ReactiveRedisTemplate redisTemplate, @@ -90,8 +91,8 @@ public Mono save(ExecutionState state) { } return Mono.when(stateOp, metaOp).then(ttlOp); }) - .doOnSuccess(v -> log.debug("[orchestration] Saved state to Redis: {}", state.correlationId())) - .doOnError(e -> log.error("[orchestration] Failed to save to Redis: {}", state.correlationId(), e)); + .doOnSuccess(v -> log.debug("[orchestration] Saved state to Redis: {}", LogFields.text(state.correlationId()))) + .doOnError(e -> log.error("[orchestration] Failed to save to Redis: {}", LogFields.text(state.correlationId()), e)); } @Override @@ -102,7 +103,7 @@ public Mono> findById(String correlationId) { try { return Optional.of(objectMapper.readValue(json, ExecutionState.class)); } catch (JsonProcessingException e) { - log.error("[orchestration] Failed to deserialize state: {}", correlationId, e); + log.error("[orchestration] Failed to deserialize state: {}", LogFields.text(correlationId), e); return Optional.empty(); } }) @@ -197,7 +198,7 @@ private MetaEntry parseMetaEntry(String metaKey, String value) { Instant updatedAt = parts.length > 2 ? Instant.parse(parts[2]) : null; return new MetaEntry(correlationId, pattern, status, updatedAt); } catch (Exception e) { - log.warn("[orchestration] Failed to parse meta entry: {}", metaKey, e); + log.warn("[orchestration] Failed to parse meta entry: {}", LogFields.text(metaKey), e); return null; } } diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/child/ChildWorkflowService.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/child/ChildWorkflowService.java index b09d00f07..387a881c7 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/child/ChildWorkflowService.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/child/ChildWorkflowService.java @@ -18,6 +18,7 @@ import org.fireflyframework.orchestration.core.observability.OrchestrationEvents; import org.fireflyframework.orchestration.workflow.engine.WorkflowEngine; +import org.fireflyframework.kernel.logging.LogFields; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -52,7 +53,7 @@ public Mono spawn(String parentCorrelationId, String childW Map input) { return engine.startWorkflow(childWorkflowId, input, null, "child:" + parentCorrelationId, false) .doOnSubscribe(s -> log.info("[child-workflow] Spawning child '{}' from parent '{}'", - childWorkflowId, parentCorrelationId)) + LogFields.text(childWorkflowId), LogFields.text(parentCorrelationId))) .doOnNext(state -> { parentToChildren.computeIfAbsent(parentCorrelationId, k -> new CopyOnWriteArrayList<>()) .add(state.correlationId()); @@ -85,7 +86,7 @@ public Mono cancelChildren(String parentCorrelationId) { return Flux.fromIterable(children) .flatMap(childId -> engine.cancelWorkflow(childId) .onErrorResume(err -> { - log.warn("[child-workflow] Failed to cancel child '{}': {}", childId, err.getMessage()); + log.warn("[child-workflow] Failed to cancel child '{}': {}", LogFields.text(childId), LogFields.text(err.getMessage())); return Mono.empty(); })) .then(); diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/engine/WorkflowEngine.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/engine/WorkflowEngine.java index b0b088cc6..ff570c824 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/engine/WorkflowEngine.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/engine/WorkflowEngine.java @@ -38,6 +38,7 @@ import org.fireflyframework.orchestration.workflow.registry.WorkflowDefinition; import org.fireflyframework.orchestration.workflow.registry.WorkflowRegistry; import org.fireflyframework.orchestration.workflow.registry.WorkflowStepDefinition; +import org.fireflyframework.kernel.logging.LogFields; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -148,9 +149,9 @@ public Mono startWorkflow(String workflowId, Map .subscribeOn(Schedulers.boundedElastic()) .subscribe( state -> log.info("[workflow] Async workflow '{}' completed with status {}", - workflowId, state.status()), + LogFields.text(workflowId), state.status()), error -> log.error("[workflow] Async workflow '{}' failed unexpectedly", - workflowId, error) + LogFields.text(workflowId), error) ); // Return initial RUNNING state immediately return Mono.just(initialState); @@ -333,7 +334,7 @@ private Mono saveToDlq(String workflowId, ExecutionContext ctx, Throwable ExecutionStatus.FAILED, error, inputs != null ? inputs : Map.of()); return dlqService.deadLetter(entry) .onErrorResume(err -> { - log.warn("[workflow] Failed to save to DLQ: {}", ctx.getCorrelationId(), err); + log.warn("[workflow] Failed to save to DLQ: {}", LogFields.text(ctx.getCorrelationId()), err); return Mono.empty(); }); } @@ -372,7 +373,7 @@ private Mono compensateWorkflow(WorkflowDefinition def, ExecutionContext c Method compMethod = findCompensationMethod(bean.getClass(), compMethodName); if (compMethod == null) { log.warn("[workflow] Compensation method '{}' not found on bean '{}'", - compMethodName, bean.getClass().getSimpleName()); + LogFields.text(compMethodName), LogFields.text(bean.getClass().getSimpleName())); return Mono.empty(); } compMethod.setAccessible(true); @@ -386,7 +387,7 @@ private Mono compensateWorkflow(WorkflowDefinition def, ExecutionContext c .then(Mono.fromRunnable(() -> events.onStepCompensated(workflowId, correlationId, stepId))) .then() .onErrorResume(e -> { - log.warn("[workflow] Compensation failed for step '{}'", stepId, e); + log.warn("[workflow] Compensation failed for step '{}'", LogFields.text(stepId), e); events.onStepCompensationFailed(workflowId, correlationId, stepId, e); return Mono.empty(); }); @@ -399,7 +400,7 @@ private Mono compensateWorkflow(WorkflowDefinition def, ExecutionContext c return mono.then() .doOnSuccess(v -> events.onStepCompensated(workflowId, correlationId, stepId)) .onErrorResume(e -> { - log.warn("[workflow] Compensation failed for step '{}'", stepId, e); + log.warn("[workflow] Compensation failed for step '{}'", LogFields.text(stepId), e); events.onStepCompensationFailed(workflowId, correlationId, stepId, e); return Mono.empty(); }); @@ -407,7 +408,7 @@ private Mono compensateWorkflow(WorkflowDefinition def, ExecutionContext c events.onStepCompensated(workflowId, correlationId, stepId); return Mono.empty(); } catch (Exception e) { - log.warn("[workflow] Compensation failed for step '{}'", stepId, e); + log.warn("[workflow] Compensation failed for step '{}'", LogFields.text(stepId), e); events.onStepCompensationFailed(workflowId, correlationId, stepId, e); return Mono.empty(); } @@ -525,7 +526,7 @@ private Mono invokeWorkflowCompleteCallbacks(WorkflowDefinition def, Execu Object[] args = resolveCallbackArgs(m, ctx); m.invoke(bean, args); } catch (Exception e) { - log.warn("[workflow] @OnWorkflowComplete callback '{}' failed", m.getName(), e); + log.warn("[workflow] @OnWorkflowComplete callback '{}' failed", LogFields.text(m.getName()), e); } }); @@ -579,7 +580,7 @@ private Mono invokeWorkflowErrorCallbacks(WorkflowDefinition def, Executio Object[] args = resolveCallbackArgs(m, error, ctx); m.invoke(bean, args); } catch (Exception e) { - log.warn("[workflow] @OnWorkflowError callback '{}' failed", m.getName(), e); + log.warn("[workflow] @OnWorkflowError callback '{}' failed", LogFields.text(m.getName()), e); } }); diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/engine/WorkflowExecutor.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/engine/WorkflowExecutor.java index 364a350d5..c18c7e5ad 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/engine/WorkflowExecutor.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/engine/WorkflowExecutor.java @@ -37,6 +37,7 @@ import org.fireflyframework.orchestration.core.model.ExecutionStatus; import org.fireflyframework.orchestration.core.persistence.ExecutionPersistenceProvider; import org.fireflyframework.orchestration.core.persistence.ExecutionState; +import org.fireflyframework.kernel.logging.LogFields; import lombok.extern.slf4j.Slf4j; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; @@ -154,7 +155,7 @@ private Mono executeStep(WorkflowDefinition def, ExecutionContext ctx, Str // Skip already-completed steps (for resume scenarios) StepStatus currentStatus = ctx.getStepStatus(stepId); if (currentStatus == StepStatus.DONE) { - log.debug("[workflow] Skipping already-completed step '{}'", stepId); + log.debug("[workflow] Skipping already-completed step '{}'", LogFields.text(stepId)); return Mono.empty(); } @@ -394,9 +395,9 @@ private Mono executeChildWorkflowStep(WorkflowDefinition def, ExecutionCon childMono.subscribeOn(Schedulers.boundedElastic()) .subscribe( result -> log.info("[workflow] Child workflow '{}' completed for step '{}'", - stepDef.childWorkflowId(), stepId), + LogFields.text(stepDef.childWorkflowId()), LogFields.text(stepId)), error -> log.warn("[workflow] Child workflow '{}' failed for step '{}'", - stepDef.childWorkflowId(), stepId, error)); + LogFields.text(stepDef.childWorkflowId()), LogFields.text(stepId), error)); ctx.setStepStatus(stepId, StepStatus.DONE); long latency = Duration.between(ctx.getStepStartedAt(stepId), Instant.now()).toMillis(); ctx.setStepLatency(stepId, latency); @@ -445,7 +446,7 @@ private Mono saveCheckpoint(String workflowId, ExecutionContext ctx) { null, ctx.getStartedAt(), Instant.now(), Optional.empty()); return persistence.save(checkpoint) .onErrorResume(err -> { - log.warn("[orchestration] Failed to save workflow checkpoint: {}", ctx.getCorrelationId(), err); + log.warn("[orchestration] Failed to save workflow checkpoint: {}", LogFields.text(ctx.getCorrelationId()), err); return Mono.empty(); }); } @@ -494,7 +495,7 @@ private Mono invokeStepCompleteCallbacks(WorkflowDefinition def, String st m.invoke(bean, args); } catch (Exception e) { log.warn("[workflow] @OnStepComplete callback '{}' failed for step '{}'", - m.getName(), stepId, e); + LogFields.text(m.getName()), LogFields.text(stepId), e); } }); diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/signal/SignalService.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/signal/SignalService.java index ca459bc27..947d69cdd 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/signal/SignalService.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/signal/SignalService.java @@ -19,6 +19,7 @@ import org.fireflyframework.orchestration.core.model.ExecutionStatus; import org.fireflyframework.orchestration.core.observability.OrchestrationEvents; import org.fireflyframework.orchestration.core.persistence.ExecutionPersistenceProvider; +import org.fireflyframework.kernel.logging.LogFields; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; @@ -56,13 +57,13 @@ public Mono signal(String correlationId, String signalName, Object return persistence.findById(correlationId) .flatMap(opt -> { if (opt.isEmpty()) { - log.warn("[signal] No workflow instance found: {}", correlationId); + log.warn("[signal] No workflow instance found: {}", LogFields.text(correlationId)); return Mono.just(SignalResult.notDelivered(correlationId, signalName)); } var state = opt.get(); if (state.status().isTerminal()) { - log.warn("[signal] Cannot signal terminal instance: {} (status={})", correlationId, state.status()); + log.warn("[signal] Cannot signal terminal instance: {} (status={})", LogFields.text(correlationId), state.status()); return Mono.just(SignalResult.notDelivered(correlationId, signalName)); } @@ -80,10 +81,10 @@ public Mono signal(String correlationId, String signalName, Object sink.tryEmitValue(effectivePayload); resumedStepId = signalName; log.info("[signal] Signal '{}' delivered and resumed waiter for instance '{}'", - signalName, correlationId); + LogFields.text(signalName), LogFields.text(correlationId)); } else { log.info("[signal] Signal '{}' buffered for instance '{}' (no active waiter)", - signalName, correlationId); + LogFields.text(signalName), LogFields.text(correlationId)); } events.onSignalDelivered(state.executionName(), correlationId, signalName); diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/timer/TimerService.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/timer/TimerService.java index afb50c8c9..28ba683db 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/timer/TimerService.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/workflow/timer/TimerService.java @@ -17,6 +17,7 @@ package org.fireflyframework.orchestration.workflow.timer; import org.fireflyframework.orchestration.core.observability.OrchestrationEvents; +import org.fireflyframework.kernel.logging.LogFields; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -49,7 +50,7 @@ public Mono schedule(String correlationId, String timerId, Duration TimerEntry entry = TimerEntry.create(correlationId, timerId, Instant.now().plus(delay), data); timers.computeIfAbsent(correlationId, k -> new CopyOnWriteArrayList<>()).add(entry); log.info("[timer] Scheduled timer '{}' for instance '{}', fires at {}", - timerId, correlationId, entry.fireAt()); + LogFields.text(timerId), LogFields.text(correlationId), entry.fireAt()); return entry; }); } @@ -61,7 +62,7 @@ public Mono scheduleAt(String correlationId, String timerId, Instant return Mono.fromCallable(() -> { TimerEntry entry = TimerEntry.create(correlationId, timerId, fireAt, data); timers.computeIfAbsent(correlationId, k -> new CopyOnWriteArrayList<>()).add(entry); - log.info("[timer] Scheduled timer '{}' for instance '{}', fires at {}", timerId, correlationId, fireAt); + log.info("[timer] Scheduled timer '{}' for instance '{}', fires at {}", LogFields.text(timerId), LogFields.text(correlationId), fireAt); return entry; }); } @@ -75,7 +76,7 @@ public Mono cancel(String correlationId, String timerId) { if (entries == null) return false; boolean removed = entries.removeIf(e -> e.timerId().equals(timerId)); if (removed) { - log.info("[timer] Cancelled timer '{}' for instance '{}'", timerId, correlationId); + log.info("[timer] Cancelled timer '{}' for instance '{}'", LogFields.text(timerId), LogFields.text(correlationId)); } return removed; }); diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/logging/OrchestrationRuntimeLogSafetyTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/logging/OrchestrationRuntimeLogSafetyTest.java new file mode 100644 index 000000000..7ee42681a --- /dev/null +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/logging/OrchestrationRuntimeLogSafetyTest.java @@ -0,0 +1,234 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.orchestration.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.AppenderBase; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.fireflyframework.orchestration.core.argument.ArgumentResolver; +import org.fireflyframework.orchestration.core.context.ExecutionContext; +import org.fireflyframework.orchestration.core.dlq.DeadLetterEntry; +import org.fireflyframework.orchestration.core.dlq.DeadLetterService; +import org.fireflyframework.orchestration.core.event.NoOpEventPublisher; +import org.fireflyframework.orchestration.core.model.*; +import org.fireflyframework.orchestration.core.observability.OrchestrationEvents; +import org.fireflyframework.orchestration.core.persistence.*; +import org.fireflyframework.orchestration.core.step.StepInvoker; +import org.fireflyframework.orchestration.persistence.redis.RedisPersistenceProvider; +import org.fireflyframework.orchestration.workflow.child.ChildWorkflowService; +import org.fireflyframework.orchestration.workflow.engine.*; +import org.fireflyframework.orchestration.workflow.registry.*; +import org.fireflyframework.orchestration.workflow.signal.SignalService; +import org.fireflyframework.orchestration.workflow.timer.TimerService; +import org.junit.jupiter.api.*; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import reactor.core.publisher.Mono; +import java.lang.reflect.Method; +import java.time.*; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.awaitility.Awaitility.await; + +class OrchestrationRuntimeLogSafetyTest { + private static final String BREAKS = "\r\n\u000b\f\u0085\u2028\u2029"; + private static final String VISIBLE = "\\r\\n\\u000b\\f\\u0085\\u2028\\u2029"; + private static final Duration LIMIT = Duration.ofSeconds(3); + private static final List> TYPES = List.of(SignalService.class, TimerService.class, + ChildWorkflowService.class, WorkflowExecutor.class, WorkflowEngine.class, RedisPersistenceProvider.class); + private final List captured = new CopyOnWriteArrayList<>(); + private final Map oldLevels = new LinkedHashMap<>(); + private final Map oldAdditive = new LinkedHashMap<>(); + private final AppenderBase appender = new AppenderBase<>() { + @Override protected void append(ILoggingEvent event) { event.prepareForDeferredProcessing(); captured.add(event); } + }; + private static final class Payload { + @Override public String toString() { throw new AssertionError("Payload must not be rendered"); } + } + private record Scenario(String name, java.util.function.Consumer run) { } + @BeforeEach void attach() { + appender.setContext((ch.qos.logback.classic.LoggerContext) LoggerFactory.getILoggerFactory()); appender.start(); + for (Class type : TYPES) { + Logger logger = (Logger) LoggerFactory.getLogger(type); + oldLevels.put(logger, logger.getLevel()); oldAdditive.put(logger, logger.isAdditive()); + logger.setLevel(Level.DEBUG); logger.setAdditive(false); logger.addAppender(appender); + } + MDC.put("correlation", "raw" + BREAKS); + } + @AfterEach void detach() { + oldLevels.forEach((logger, level) -> { logger.detachAppender(appender); logger.setLevel(level); logger.setAdditive(oldAdditive.get(logger)); }); + appender.stop(); MDC.clear(); + } + private ILoggingEvent event(String template, Level level, Throwable throwable, Object... expected) { + await().atMost(LIMIT).until(() -> captured.stream().anyMatch(e -> e.getMessage().equals(template))); + ILoggingEvent e = captured.stream().filter(x -> x.getMessage().equals(template)).findFirst().orElseThrow(); + assertEquals(level, e.getLevel()); assertEquals(template, e.getMessage()); + Object[] escaped = Arrays.stream(expected).map(v -> v instanceof String text ? text.replace(BREAKS, VISIBLE) : v).toArray(); + assertArrayEquals(escaped, e.getArgumentArray()); + for (char c : BREAKS.toCharArray()) assertFalse(e.getFormattedMessage().contains(String.valueOf(c))); + if (throwable != null) assertSame(throwable, ((ThrowableProxy) e.getThrowableProxy()).getThrowable()); + return e; + } + private static ExecutionState state(String id, ExecutionStatus status) { + return new ExecutionState(id, "workflow", ExecutionPattern.WORKFLOW, status, Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Set.of(), List.of(), null, Instant.EPOCH, Instant.EPOCH, Optional.empty()); + } + public static final class Step { + public Mono run() { return Mono.just("result"); } + } + private static WorkflowDefinition definition(String id, TriggerMode mode) { + try { + var step = new WorkflowStepDefinition("step", "Step", "", List.of(), 0, "", 3000, + RetryPolicy.NO_RETRY, "", false, false, "", new Step(), Step.class.getMethod("run")); + return new WorkflowDefinition(id, "Workflow", "", "1", List.of(step), mode, "", 3000, + RetryPolicy.NO_RETRY, null, null, null, null); + } catch (NoSuchMethodException error) { throw new AssertionError(error); } + } + private Object invoke(Object target, String method, Class[] signature, Object... args) { + try { Method m = target.getClass().getDeclaredMethod(method, signature); m.setAccessible(true); return m.invoke(target, args); } + catch (ReflectiveOperationException error) { throw new AssertionError(error); } + } + @TestFactory Stream diagnosticFieldsAndOrdinaryValues() { + List scenarios = List.of( + new Scenario("missing signal", this::missingSignal), new Scenario("terminal signal", this::terminalSignal), + new Scenario("buffered signal", suffix -> deliveredSignal(suffix, false)), new Scenario("resumed signal", suffix -> deliveredSignal(suffix, true)), + new Scenario("relative timer", suffix -> timer(suffix, false)), new Scenario("absolute timer", suffix -> timer(suffix, true)), + new Scenario("cancelled timer", this::cancelledTimer), new Scenario("child spawn", this::childSpawn), + new Scenario("child cancellation error", this::childCancel), new Scenario("checkpoint failure", this::checkpoint), + new Scenario("dead letter failure", this::deadLetter), new Scenario("async completion", suffix -> asyncWorkflow(suffix, false)), + new Scenario("async failure", suffix -> asyncWorkflow(suffix, true)), new Scenario("redis prefix", this::redisPrefix), + new Scenario("redis save", suffix -> redisSave(suffix, false)), new Scenario("redis save failure", suffix -> redisSave(suffix, true)), + new Scenario("redis malformed state", this::redisMalformed), new Scenario("redis malformed metadata", this::redisMetadata)); + return scenarios.stream().flatMap(s -> Stream.of("", BREAKS).map(suffix -> DynamicTest.dynamicTest( + s.name() + (suffix.isEmpty() ? " ordinary" : " line breaks"), () -> { captured.clear(); s.run().accept(suffix); }))); + } + private void missingSignal(String suffix) { + String id = "missing" + suffix; + var result = new SignalService(new InMemoryPersistenceProvider(), new OrchestrationEvents() {}).signal(id, "sig", null).block(LIMIT); + assertNotNull(result); assertFalse(result.delivered()); assertEquals(id, result.correlationId()); + event("[signal] No workflow instance found: {}", Level.WARN, null, id); + } + private void terminalSignal(String suffix) { + String id = "terminal" + suffix; var p = new InMemoryPersistenceProvider(); p.save(state(id, ExecutionStatus.COMPLETED)).block(LIMIT); + assertFalse(new SignalService(p, new OrchestrationEvents() {}).signal(id, "sig", null).block(LIMIT).delivered()); + event("[signal] Cannot signal terminal instance: {} (status={})", Level.WARN, null, id, ExecutionStatus.COMPLETED); + } + private void deliveredSignal(String suffix, boolean waiting) { + String id = "instance" + suffix, name = "approval" + suffix; Payload payload = new Payload(); + var p = new InMemoryPersistenceProvider(); p.save(state(id, ExecutionStatus.RUNNING)).block(LIMIT); + var events = mock(OrchestrationEvents.class); var service = new SignalService(p, events); + Mono waiter = waiting ? service.waitForSignal(id, name) : null; + var result = service.signal(id, name, payload).block(LIMIT); + assertTrue(result.delivered()); assertEquals(id, result.correlationId()); assertEquals(name, result.signalName()); + assertSame(payload, waiting ? waiter.block(LIMIT) : service.getPendingSignals(id).getFirst().payload()); + verify(events).onSignalDelivered("workflow", id, name); + event(waiting ? "[signal] Signal '{}' delivered and resumed waiter for instance '{}'" : "[signal] Signal '{}' buffered for instance '{}' (no active waiter)", Level.INFO, null, name, id); + } + private void timer(String suffix, boolean absolute) { + String id = "instance" + suffix, timer = "timer" + suffix; Payload payload = new Payload(); var service = new TimerService(new OrchestrationEvents() {}); + var entry = absolute ? service.scheduleAt(id, timer, Instant.EPOCH, payload).block(LIMIT) : service.schedule(id, timer, Duration.ofSeconds(2), payload).block(LIMIT); + assertEquals(id, entry.correlationId()); assertEquals(timer, entry.timerId()); assertSame(payload, entry.data()); + assertSame(entry, service.getPendingTimers(id).getFirst()); + event("[timer] Scheduled timer '{}' for instance '{}', fires at {}", Level.INFO, null, timer, id, entry.fireAt()); + } + private void cancelledTimer(String suffix) { + String id = "instance" + suffix, name = "timer" + suffix; var service = new TimerService(new OrchestrationEvents() {}); + service.scheduleAt(id, name, Instant.EPOCH, new Payload()).block(LIMIT); assertTrue(service.cancel(id, name).block(LIMIT)); assertTrue(service.getPendingTimers(id).isEmpty()); + event("[timer] Cancelled timer '{}' for instance '{}'", Level.INFO, null, name, id); + } + private void childSpawn(String suffix) { + String parent = "parent" + suffix, child = "child" + suffix; Map input = Map.of("raw", new Payload()); + var engine = mock(WorkflowEngine.class); var events = mock(OrchestrationEvents.class); var state = state("child-instance", ExecutionStatus.COMPLETED); + when(engine.startWorkflow(eq(child), same(input), isNull(), eq("child:" + parent), eq(false))).thenReturn(Mono.just(state)); + var service = new ChildWorkflowService(engine, events); var result = service.spawn(parent, child, input).block(LIMIT); + assertEquals(parent, result.parentCorrelationId()); assertEquals(List.of("child-instance"), service.getChildren(parent)); + verify(events).onChildWorkflowStarted("workflow", parent, child, "child-instance"); + event("[child-workflow] Spawning child '{}' from parent '{}'", Level.INFO, null, child, parent); + } + private void childCancel(String suffix) { + String child = "child" + suffix; var engine = mock(WorkflowEngine.class); var failure = new IllegalStateException("unavailable" + suffix); + when(engine.startWorkflow(anyString(), anyMap(), isNull(), anyString(), eq(false))).thenReturn(Mono.just(state(child, ExecutionStatus.RUNNING))); + when(engine.cancelWorkflow(child)).thenReturn(Mono.error(failure)); var service = new ChildWorkflowService(engine, new OrchestrationEvents() {}); + service.spawn("parent", "workflow", Map.of()).block(LIMIT); service.cancelChildren("parent").block(LIMIT); verify(engine).cancelWorkflow(child); + event("[child-workflow] Failed to cancel child '{}': {}", Level.WARN, null, child, failure.getMessage()); + } + @SuppressWarnings("unchecked") private void checkpoint(String suffix) { + String id = "checkpoint" + suffix; var failure = new IllegalStateException("write failed"); var p = mock(ExecutionPersistenceProvider.class); + when(p.save(any())).thenReturn(Mono.error(failure)); var ctx = ExecutionContext.forWorkflow(id, "workflow", false); + var executor = new WorkflowExecutor(new StepInvoker(new ArgumentResolver()), new OrchestrationEvents() {}, new NoOpEventPublisher(), null, null, p); + ((Mono) invoke(executor, "saveCheckpoint", new Class[]{String.class, ExecutionContext.class}, "workflow", ctx)).block(LIMIT); + var saved = org.mockito.ArgumentCaptor.forClass(ExecutionState.class); verify(p).save(saved.capture()); assertEquals(id, saved.getValue().correlationId()); + event("[orchestration] Failed to save workflow checkpoint: {}", Level.WARN, failure, id); + } + @SuppressWarnings("unchecked") private void deadLetter(String suffix) { + String id = "dlq" + suffix; var failure = new IllegalStateException("queue unavailable"); var dlq = mock(DeadLetterService.class); + when(dlq.deadLetter(any())).thenReturn(Mono.error(failure)); var ctx = ExecutionContext.forWorkflow(id, "workflow", false); + var engine = new WorkflowEngine(new WorkflowRegistry(), null, null, new InMemoryPersistenceProvider(), new OrchestrationEvents() {}, new NoOpEventPublisher(), dlq); + ((Mono) invoke(engine, "saveToDlq", new Class[]{String.class, ExecutionContext.class, Throwable.class, Map.class}, "workflow", ctx, failure, Map.of())).block(LIMIT); + var entry = org.mockito.ArgumentCaptor.forClass(DeadLetterEntry.class); verify(dlq).deadLetter(entry.capture()); assertEquals(id, entry.getValue().correlationId()); + event("[workflow] Failed to save to DLQ: {}", Level.WARN, failure, id); + } + private void asyncWorkflow(String suffix, boolean failed) { + String id = "async" + suffix; var registry = new WorkflowRegistry(); registry.register(definition(id, TriggerMode.ASYNC)); var executor = mock(WorkflowExecutor.class); + when(executor.execute(any(), any())).thenAnswer(inv -> Mono.just(inv.getArgument(1, ExecutionContext.class))); + var p = mock(ExecutionPersistenceProvider.class); var failure = new IllegalStateException("persistence unavailable"); + when(p.save(any())).thenReturn(Mono.empty()); if (failed) when(p.save(any())).thenReturn(Mono.empty(), Mono.error(failure)); + var engine = new WorkflowEngine(registry, executor, null, p, new OrchestrationEvents() {}, new NoOpEventPublisher()); + assertEquals(id, engine.startWorkflow(id, Map.of(), "raw-correlation", "api", false).block(LIMIT).executionName()); + event(failed ? "[workflow] Async workflow '{}' failed unexpectedly" : "[workflow] Async workflow '{}' completed with status {}", failed ? Level.ERROR : Level.INFO, failed ? failure : null, + failed ? new Object[]{id} : new Object[]{id, ExecutionStatus.COMPLETED}); + var saved = org.mockito.ArgumentCaptor.forClass(ExecutionState.class); verify(p, atLeastOnce()).save(saved.capture()); assertTrue(saved.getAllValues().stream().allMatch(s -> s.executionName().equals(id))); + } + private RedisPersistenceProvider redis(ReactiveRedisTemplate template, String prefix) { + return new RedisPersistenceProvider(template, new ObjectMapper().registerModule(new Jdk8Module()).registerModule(new JavaTimeModule()), prefix, null); + } + private void redisPrefix(String suffix) { + String prefix = "tenant:" + suffix; redis(null, prefix); + event("[orchestration] RedisPersistenceProvider initialized with prefix: {}", Level.INFO, null, prefix); + } + @SuppressWarnings("unchecked") private void redisSave(String suffix, boolean fail) { + String id = "state" + suffix, prefix = "prefix:"; var template = (ReactiveRedisTemplate) mock(ReactiveRedisTemplate.class); + var ops = (ReactiveValueOperations) mock(ReactiveValueOperations.class); when(template.opsForValue()).thenReturn(ops); + var failure = new IllegalStateException("redis unavailable"); when(ops.set(anyString(), anyString())).thenReturn(fail ? Mono.error(failure) : Mono.just(true)); + var saved = state(id, ExecutionStatus.RUNNING); var publisher = redis(template, prefix).save(saved); + if (fail) assertSame(failure, assertThrows(IllegalStateException.class, () -> publisher.block(LIMIT))); else publisher.block(LIMIT); + verify(ops).set(eq(prefix + "state:" + id), contains("correlationId")); verify(ops).set(eq(prefix + "meta:" + id), contains("WORKFLOW")); + ILoggingEvent e = event(fail ? "[orchestration] Failed to save to Redis: {}" : "[orchestration] Saved state to Redis: {}", fail ? Level.ERROR : Level.DEBUG, fail ? failure : null, id); + assertEquals("raw" + BREAKS, e.getMDCPropertyMap().get("correlation")); + } + @SuppressWarnings("unchecked") private void redisMalformed(String suffix) { + String id = "lookup" + suffix; var template = (ReactiveRedisTemplate) mock(ReactiveRedisTemplate.class); var ops = (ReactiveValueOperations) mock(ReactiveValueOperations.class); + when(template.opsForValue()).thenReturn(ops); when(ops.get("prefix:state:" + id)).thenReturn(Mono.just("{")); + assertTrue(redis(template, "prefix:").findById(id).block(LIMIT).isEmpty()); verify(ops).get("prefix:state:" + id); + ILoggingEvent e = event("[orchestration] Failed to deserialize state: {}", Level.ERROR, null, id); + assertInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class, ((ThrowableProxy)e.getThrowableProxy()).getThrowable()); + } + private void redisMetadata(String suffix) { + String key = "prefix:meta:entry" + suffix; var provider = redis(null, "prefix:"); + assertNull(invoke(provider, "parseMetaEntry", new Class[]{String.class,String.class}, key, "unsupported|RUNNING")); + ILoggingEvent e = event("[orchestration] Failed to parse meta entry: {}", Level.WARN, null, key); + assertInstanceOf(IllegalArgumentException.class, ((ThrowableProxy)e.getThrowableProxy()).getThrowable()); + } + @Test void nullPrefixRemainsNullDiagnosticAndDefaultStoragePrefix() { + redis(null, null); event("[orchestration] RedisPersistenceProvider initialized with prefix: {}", Level.INFO, null, new Object[]{null}); + } +} From dbaf8f4d4fb4469df75b6556c1f6630b7645e422 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 27 Oct 2026 07:35:29 -0700 Subject: [PATCH 37/44] Bound persistence diagnostic fields without changing stored workflow state --- .../cache/CachePersistenceProvider.java | 5 +- .../EventSourcedPersistenceProvider.java | 5 +- .../PersistenceProviderLogSafetyTest.java | 110 ++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 modules/orchestration/src/test/java/org/fireflyframework/orchestration/logging/PersistenceProviderLogSafetyTest.java diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/cache/CachePersistenceProvider.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/cache/CachePersistenceProvider.java index 5c5485ab7..e139ff024 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/cache/CachePersistenceProvider.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/cache/CachePersistenceProvider.java @@ -16,6 +16,7 @@ package org.fireflyframework.orchestration.persistence.cache; +import org.fireflyframework.kernel.logging.LogFields; import org.fireflyframework.cache.core.CacheAdapter; import org.fireflyframework.orchestration.core.model.ExecutionPattern; import org.fireflyframework.orchestration.core.model.ExecutionStatus; @@ -73,8 +74,8 @@ public Mono save(ExecutionState state) { return cacheAdapter.put(stateKey, state, defaultTtl) .then(cacheAdapter.put(patternKey, state.correlationId(), defaultTtl)) .then(cacheAdapter.put(statusKey, state.correlationId(), defaultTtl)) - .doOnSuccess(v -> log.debug("[orchestration] Saved execution state: {}", state.correlationId())) - .doOnError(e -> log.error("[orchestration] Failed to save execution state: {}", state.correlationId(), e)); + .doOnSuccess(v -> log.debug("[orchestration] Saved execution state: {}", LogFields.text(state.correlationId()))) + .doOnError(e -> log.error("[orchestration] Failed to save execution state: {}", LogFields.text(state.correlationId()), e)); } @Override diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/eventsourced/EventSourcedPersistenceProvider.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/eventsourced/EventSourcedPersistenceProvider.java index c23014d3a..815b2b8fc 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/eventsourced/EventSourcedPersistenceProvider.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/persistence/eventsourced/EventSourcedPersistenceProvider.java @@ -16,6 +16,7 @@ package org.fireflyframework.orchestration.persistence.eventsourced; +import org.fireflyframework.kernel.logging.LogFields; import com.fasterxml.jackson.databind.ObjectMapper; import org.fireflyframework.eventsourcing.domain.Event; import org.fireflyframework.eventsourcing.domain.StoredEventEnvelope; @@ -102,9 +103,9 @@ public Mono save(ExecutionState state) { })); }) .then() - .doOnSuccess(v -> log.debug("[orchestration] Saved state as event: {}", state.correlationId())) + .doOnSuccess(v -> log.debug("[orchestration] Saved state as event: {}", LogFields.text(state.correlationId()))) .doOnError(e -> log.error("[orchestration] Failed to save event-sourced state: {}", - state.correlationId(), e)); + LogFields.text(state.correlationId()), e)); } @Override diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/logging/PersistenceProviderLogSafetyTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/logging/PersistenceProviderLogSafetyTest.java new file mode 100644 index 000000000..c5536831b --- /dev/null +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/logging/PersistenceProviderLogSafetyTest.java @@ -0,0 +1,110 @@ +/* Copyright 2026 Firefly Software Foundation. Licensed under the Apache License, Version 2.0. */ +package org.fireflyframework.orchestration.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.AppenderBase; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.fireflyframework.cache.core.CacheAdapter; +import org.fireflyframework.eventsourcing.domain.Event; +import org.fireflyframework.eventsourcing.store.EventStore; +import org.fireflyframework.orchestration.core.model.*; +import org.fireflyframework.orchestration.core.persistence.ExecutionState; +import org.fireflyframework.orchestration.persistence.cache.CachePersistenceProvider; +import org.fireflyframework.orchestration.persistence.eventsourced.EventSourcedPersistenceProvider; +import org.junit.jupiter.api.*; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import reactor.core.publisher.Mono; +import java.time.*; +import java.util.*; +import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class PersistenceProviderLogSafetyTest { + private static final String BREAKS = "\r\n\u000b\f\u0085\u2028\u2029"; + private static final String VISIBLE = "\\r\\n\\u000b\\f\\u0085\\u2028\\u2029"; + private static final Duration LIMIT = Duration.ofSeconds(2); + private final List events = new ArrayList<>(); + private final Map levels = new LinkedHashMap<>(); + private final Map additive = new LinkedHashMap<>(); + private final AppenderBase appender = new AppenderBase<>() { + protected void append(ILoggingEvent event) { event.prepareForDeferredProcessing(); events.add(event); } + }; + @BeforeEach void attach() { + appender.setContext((ch.qos.logback.classic.LoggerContext) LoggerFactory.getILoggerFactory());appender.start(); + for (Class type : List.of(CachePersistenceProvider.class, EventSourcedPersistenceProvider.class)) { + Logger logger = (Logger) LoggerFactory.getLogger(type);levels.put(logger, logger.getLevel());additive.put(logger, logger.isAdditive()); + logger.setLevel(Level.DEBUG);logger.setAdditive(false);logger.addAppender(appender); + } + MDC.put("correlation", "raw" + BREAKS); + } + @AfterEach void detach() { + levels.forEach((logger, level) -> { logger.detachAppender(appender);logger.setLevel(level);logger.setAdditive(additive.get(logger)); }); + appender.stop();MDC.clear(); + } + @TestFactory Stream ordinaryAndLineBoundarySaveDiagnostics() { + return Stream.of(false, true).flatMap(eventSourced -> Stream.of(false, true).flatMap(failure -> Stream.of("ordinary-id", "id" + BREAKS) + .map(id -> DynamicTest.dynamicTest((eventSourced ? "event" : "cache") + (failure ? " error " : " success ") + (id.equals("ordinary-id") ? "ordinary" : "visible boundaries"), + () -> save(eventSourced, failure, id, false))))); + } + @Test void nullCacheSuccessKeepsNullAndRawCacheKey() { save(false, false, null, false); } + @Test void nullCacheErrorKeepsNullAndExactThrowable() { save(false, true, null, false); } + @Test void disabledCacheDiagnosticsDoNotChangeStoredIdentity() { save(false, false, "id" + BREAKS, true); } + @Test void disabledEventDiagnosticsDoNotChangeSerializedIdentity() { save(true, false, "id" + BREAKS, true); } + private static ExecutionState state(String id) { + return new ExecutionState(id, "workflow", ExecutionPattern.WORKFLOW, ExecutionStatus.RUNNING, + Map.of("result", "raw" + BREAKS), Map.of(), Map.of(), Map.of(), Map.of(), Map.of("header", "raw" + BREAKS), + Set.of(), List.of(), null, Instant.EPOCH, Instant.EPOCH, Optional.empty()); + } + private void save(boolean eventSourced, boolean failure, String id, boolean disabled) { + events.clear(); + if (disabled) levels.keySet().forEach(logger -> logger.setLevel(Level.OFF)); + var state = state(id);var error = new IllegalStateException("ordinary provider failure");Mono work; + CacheAdapter cache = null;EventStore store = null;EventSourcedPersistenceProvider eventProvider = null; + if (eventSourced) { + store = mock(EventStore.class); + when(store.getAggregateVersion(any(), eq("orchestration-execution"))).thenReturn(Mono.just(0L)); + when(store.appendEvents(any(), eq("orchestration-execution"), anyList(), eq(0L), eq(Map.of()))) + .thenReturn(failure ? Mono.error(error) : Mono.empty()); + eventProvider = new EventSourcedPersistenceProvider(store, new ObjectMapper().registerModule(new JavaTimeModule()).registerModule(new Jdk8Module())); + work = eventProvider.save(state); + } else { + cache = mock(CacheAdapter.class); + when(cache.put(any(), any(), any(Duration.class))).thenReturn(failure ? Mono.error(error) : Mono.empty()); + work = new CachePersistenceProvider(cache, Duration.ofMinutes(5)).save(state); + } + if (failure) assertSame(error, assertThrows(IllegalStateException.class, () -> work.block(LIMIT))); + else assertNull(work.block(LIMIT)); + if (eventSourced) { + var calls = org.mockito.ArgumentCaptor.forClass(List.class); + verify(store).appendEvents(any(), eq("orchestration-execution"), calls.capture(), eq(0L), eq(Map.of())); + Event event = (Event) calls.getValue().getFirst(); + assertEquals(id, event.getMetadata().get("correlationId")); + assertEquals(Map.of("header", "raw" + BREAKS), event.getMetadata().get("headers")); + assertEquals(id, eventProvider.getProjection().findByPattern(ExecutionPattern.WORKFLOW).getFirst().correlationId()); + } else { + verify(cache).put(eq("orchestration:state:" + id), same(state), eq(Duration.ofMinutes(5))); + verify(cache).put(eq("orchestration:pattern:WORKFLOW:" + id), eq(id), eq(Duration.ofMinutes(5))); + verify(cache).put(eq("orchestration:status:RUNNING:" + id), eq(id), eq(Duration.ofMinutes(5))); + } + assertEquals("raw" + BREAKS, MDC.get("correlation")); + String template = eventSourced ? failure ? "[orchestration] Failed to save event-sourced state: {}" : "[orchestration] Saved state as event: {}" + : failure ? "[orchestration] Failed to save execution state: {}" : "[orchestration] Saved execution state: {}"; + var matching = events.stream().filter(e -> e.getMessage().equals(template)).toList(); + if (disabled) { assertTrue(matching.isEmpty());return; } + assertEquals(1, matching.size());var logged = matching.getFirst(); + assertEquals(failure ? Level.ERROR : Level.DEBUG, logged.getLevel()); + assertArrayEquals(new Object[]{id == null ? null : id.replace(BREAKS, VISIBLE)}, logged.getArgumentArray()); + assertEquals("raw" + BREAKS, logged.getMDCPropertyMap().get("correlation")); + if (failure) assertSame(error, ((ThrowableProxy) logged.getThrowableProxy()).getThrowable()); + else assertNull(logged.getThrowableProxy()); + for (char value : BREAKS.toCharArray()) assertFalse(logged.getFormattedMessage().contains(String.valueOf(value))); + } +} From 24f9abfdb45444d7148a9c97b41718217bf84c50 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 27 Oct 2026 13:53:19 -0700 Subject: [PATCH 38/44] Preserve indexed auto-configuration exclusions during observability setup --- ...ObservabilityEnvironmentPostProcessor.java | 12 ++- ...ndexedAutoConfigurationExclusionsTest.java | 87 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 modules/observability/src/test/java/org/fireflyframework/observability/autoconfigure/IndexedAutoConfigurationExclusionsTest.java diff --git a/modules/observability/src/main/java/org/fireflyframework/observability/FireflyObservabilityEnvironmentPostProcessor.java b/modules/observability/src/main/java/org/fireflyframework/observability/FireflyObservabilityEnvironmentPostProcessor.java index 84012fe66..f176496f3 100644 --- a/modules/observability/src/main/java/org/fireflyframework/observability/FireflyObservabilityEnvironmentPostProcessor.java +++ b/modules/observability/src/main/java/org/fireflyframework/observability/FireflyObservabilityEnvironmentPostProcessor.java @@ -3,6 +3,8 @@ import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; import org.springframework.boot.SpringApplication; import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.core.Ordered; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MapPropertySource; @@ -10,7 +12,9 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Properties; @@ -154,9 +158,11 @@ private void configureTracingBridge(ConfigurableEnvironment environment, Map existing = Binder.get(environment) + .bind(EXCLUDE_PROPERTY, Bindable.listOf(String.class)).orElse(List.of()); + List merged = new ArrayList<>(existing); + merged.add(toExclude); + props.put(EXCLUDE_PROPERTY, String.join(",", merged)); } private void configureMetricsExporter(ConfigurableEnvironment environment, Map props) { diff --git a/modules/observability/src/test/java/org/fireflyframework/observability/autoconfigure/IndexedAutoConfigurationExclusionsTest.java b/modules/observability/src/test/java/org/fireflyframework/observability/autoconfigure/IndexedAutoConfigurationExclusionsTest.java new file mode 100644 index 000000000..21a9a756b --- /dev/null +++ b/modules/observability/src/test/java/org/fireflyframework/observability/autoconfigure/IndexedAutoConfigurationExclusionsTest.java @@ -0,0 +1,87 @@ +package org.fireflyframework.observability.autoconfigure; + +import org.fireflyframework.observability.FireflyObservabilityEnvironmentPostProcessor; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.mock.env.MockEnvironment; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +class IndexedAutoConfigurationExclusionsTest { + private static final String KEY = "spring.autoconfigure.exclude"; + private static final List DATABASE = List.of( + "org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", + "org.springframework.boot.r2dbc.autoconfigure.R2dbcAutoConfiguration", + "org.springframework.boot.data.r2dbc.autoconfigure.DataR2dbcAutoConfiguration", + "org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration"); + private static final String BRAVE = + "org.springframework.boot.micrometer.tracing.brave.autoconfigure.BraveAutoConfiguration"; + + private MockEnvironment indexedEnvironment(boolean lowerScalar) { + var environment = new MockEnvironment(); + if (lowerScalar) { + environment.getPropertySources().addLast(new MapPropertySource("frameworkWebDefaults", + Map.of(KEY, "org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration"))); + } + var yaml = new YamlPropertiesFactoryBean(); + String document = "spring:\n autoconfigure:\n exclude:\n" + + DATABASE.stream().map(value -> " - " + value + "\n") + .collect(java.util.stream.Collectors.joining()); + yaml.setResources(new ByteArrayResource(document.getBytes(StandardCharsets.UTF_8))); + environment.getPropertySources().addFirst(new PropertiesPropertySource("indexedProfile", + Objects.requireNonNull(yaml.getObject()))); + return environment; + } + + private List bound(MockEnvironment environment) { + return Binder.get(environment).bind(KEY, Bindable.listOf(String.class)).orElseThrow(IllegalStateException::new); + } + + @Test + void yamlIndexedExclusionsSurviveTracingBridgeConfiguration() { + var environment = indexedEnvironment(false); + assertThat(bound(environment)).containsExactlyElementsOf(DATABASE); + new FireflyObservabilityEnvironmentPostProcessor() + .postProcessEnvironment(environment, new SpringApplication()); + assertThat(bound(environment)).containsAll(DATABASE).contains(BRAVE); + } + + @Test + void higherPriorityIndexedListWinsOverLowerScalarDefault() { + var environment = indexedEnvironment(true); + assertThat(bound(environment)).containsExactlyElementsOf(DATABASE); + new FireflyObservabilityEnvironmentPostProcessor() + .postProcessEnvironment(environment, new SpringApplication()); + assertThat(bound(environment)).containsAll(DATABASE).contains(BRAVE) + .doesNotContain("org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration"); + } + + @Test + void existingCommaSeparatedExclusionsRemainInOrder() { + var environment = new MockEnvironment().withProperty(KEY, String.join(",", DATABASE)); + new FireflyObservabilityEnvironmentPostProcessor() + .postProcessEnvironment(environment, new SpringApplication()); + assertThat(bound(environment)).containsExactly(DATABASE.get(0), DATABASE.get(1), + DATABASE.get(2), DATABASE.get(3), BRAVE); + } + + @Test + void disabledTracingDoesNotRewriteTheIndexedList() { + var environment = indexedEnvironment(true) + .withProperty("firefly.observability.tracing.enabled", "false"); + new FireflyObservabilityEnvironmentPostProcessor() + .postProcessEnvironment(environment, new SpringApplication()); + assertThat(bound(environment)).containsExactlyElementsOf(DATABASE); + } +} From 494db72596f9928818387837d1953758213072d0 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 27 Oct 2026 14:31:19 -0700 Subject: [PATCH 39/44] Honor the orchestration REST switch for workflow management --- .../config/WorkflowAutoConfiguration.java | 1 + .../config/WorkflowRestSwitchTest.java | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/WorkflowRestSwitchTest.java diff --git a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/WorkflowAutoConfiguration.java b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/WorkflowAutoConfiguration.java index 8b6be47d5..565fcf2cb 100644 --- a/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/WorkflowAutoConfiguration.java +++ b/modules/orchestration/src/main/java/org/fireflyframework/orchestration/config/WorkflowAutoConfiguration.java @@ -155,6 +155,7 @@ public WorkflowService workflowService(WorkflowEngine engine, SignalService sign @Bean @ConditionalOnMissingBean @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + @ConditionalOnProperty(name = "firefly.orchestration.rest.enabled", havingValue = "true", matchIfMissing = true) public WorkflowController workflowController(WorkflowEngine engine, WorkflowRegistry registry, SignalService signalService, TimerService timerService) { log.info("[orchestration] Workflow REST controller initialized"); diff --git a/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/WorkflowRestSwitchTest.java b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/WorkflowRestSwitchTest.java new file mode 100644 index 000000000..9c7608c47 --- /dev/null +++ b/modules/orchestration/src/test/java/org/fireflyframework/orchestration/config/WorkflowRestSwitchTest.java @@ -0,0 +1,67 @@ +package org.fireflyframework.orchestration.config; + +import org.fireflyframework.orchestration.core.argument.ArgumentResolver; +import org.fireflyframework.orchestration.core.step.StepInvoker; +import org.fireflyframework.orchestration.core.observability.OrchestrationEvents; +import org.fireflyframework.orchestration.core.observability.OrchestrationLoggerEvents; +import org.fireflyframework.orchestration.core.event.OrchestrationEventPublisher; +import org.fireflyframework.orchestration.core.event.NoOpEventPublisher; +import org.fireflyframework.orchestration.core.persistence.ExecutionPersistenceProvider; +import org.fireflyframework.orchestration.core.persistence.InMemoryPersistenceProvider; +import org.fireflyframework.orchestration.workflow.engine.WorkflowEngine; +import org.fireflyframework.orchestration.workflow.registry.WorkflowRegistry; +import org.fireflyframework.orchestration.workflow.rest.WorkflowController; +import org.fireflyframework.orchestration.workflow.service.WorkflowService; +import org.fireflyframework.orchestration.workflow.signal.SignalService; +import org.fireflyframework.orchestration.workflow.timer.TimerService; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +class WorkflowRestSwitchTest { + static class Dependencies { + @Bean StepInvoker stepInvoker() { return new StepInvoker(new ArgumentResolver()); } + @Bean OrchestrationEvents events() { return new OrchestrationLoggerEvents(); } + @Bean ExecutionPersistenceProvider persistence() { return new InMemoryPersistenceProvider(); } + @Bean OrchestrationEventPublisher publisher() { return new NoOpEventPublisher(); } + } + + private ReactiveWebApplicationContextRunner runner() { + return new ReactiveWebApplicationContextRunner().withUserConfiguration(Dependencies.class) + .withConfiguration(AutoConfigurations.of(WorkflowAutoConfiguration.class)); + } + + @Test + void disabledRestRetainsTheActualWorkflowEngineAndServices() { + runner().withPropertyValues("firefly.orchestration.rest.enabled=false").run(context -> { + assertThat(context).hasNotFailed().doesNotHaveBean(WorkflowController.class) + .hasSingleBean(WorkflowEngine.class).hasSingleBean(WorkflowService.class) + .hasSingleBean(WorkflowRegistry.class).hasSingleBean(SignalService.class) + .hasSingleBean(TimerService.class); + }); + } + + @Test + void enabledRestKeepsTheExistingController() { + runner().withPropertyValues("firefly.orchestration.rest.enabled=true").run(context -> + assertThat(context).hasNotFailed().hasSingleBean(WorkflowController.class) + .hasSingleBean(WorkflowEngine.class)); + } + + @Test + void missingRestPropertyPreservesBackwardCompatibility() { + runner().run(context -> assertThat(context).hasNotFailed() + .hasSingleBean(WorkflowController.class).hasSingleBean(WorkflowEngine.class)); + } + + @Test + void existingWorkflowDisableRemainsIndependentFromTheRestSwitch() { + runner().withPropertyValues("firefly.orchestration.rest.enabled=true", + "firefly.orchestration.workflow.enabled=false").run(context -> + assertThat(context).hasNotFailed().doesNotHaveBean(WorkflowController.class) + .doesNotHaveBean(WorkflowEngine.class)); + } +} From a2469ed673590cc7c9ac5610adae0354dc807109 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 27 Oct 2026 15:49:34 -0700 Subject: [PATCH 40/44] Project the authenticated issuer into OPA policy subjects --- .../adapter/opa/OpaPolicyDecisionAdapter.java | 3 + .../OpaPolicyDecisionAdapterIssuerTest.java | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 modules/security-adapter-opa/src/test/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapterIssuerTest.java diff --git a/modules/security-adapter-opa/src/main/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapter.java b/modules/security-adapter-opa/src/main/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapter.java index 337a719ff..37b85eb3e 100644 --- a/modules/security-adapter-opa/src/main/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapter.java +++ b/modules/security-adapter-opa/src/main/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapter.java @@ -54,6 +54,9 @@ public OpaPolicyDecisionAdapter(WebClient webClient, String decisionPath) { public Mono authorize(SecurityPrincipal principal, String action, String resource, Map context) { Map subject = new HashMap<>(); subject.put("subject", principal.subject()); + if (principal.issuer() != null) { + subject.put("issuer", principal.issuer()); + } subject.put("authorities", principal.authorities()); subject.put("scopes", principal.scopes()); subject.put("tenantId", principal.tenantId()); diff --git a/modules/security-adapter-opa/src/test/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapterIssuerTest.java b/modules/security-adapter-opa/src/test/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapterIssuerTest.java new file mode 100644 index 000000000..51daa7f61 --- /dev/null +++ b/modules/security-adapter-opa/src/test/java/org/fireflyframework/security/adapter/opa/OpaPolicyDecisionAdapterIssuerTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Firefly Software Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.fireflyframework.security.adapter.opa; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.time.Duration; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class OpaPolicyDecisionAdapterIssuerTest { + private static final String ISSUER = "https://issuer.example.test/staff"; + private static final Set LEGACY_FIELDS = Set.of("subject", "authorities", "scopes", "tenantId"); + private final AtomicReference captured = new AtomicReference<>(); + + private JsonNode authorize(String issuer, Map context) { + var principal = SecurityPrincipal.builder().subject("ordinary-staff").issuer(issuer) + .tenantId("source-tenant").authorities(Set.of("STAFF_READER")).scopes(Set.of("read")) + .claims(Map.of("private-claim", "not-policy-input")) + .attributes(Map.of("private-attribute", "not-policy-input")).build(); + var web = WebClient.builder().baseUrl("https://policy.example.test") + .exchangeFunction(request -> { + var output = new MockClientHttpRequest(HttpMethod.POST, URI.create("https://policy.example.test")); + return request.writeTo(output, ExchangeStrategies.withDefaults()) + .then(Mono.defer(output::getBodyAsString)).flatMap(body -> Mono.fromCallable(() -> { + captured.set(new ObjectMapper().readTree(body).get("input")); + return ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json").body("{\"result\":true}").build(); + })); + }).build(); + var decision = new OpaPolicyDecisionAdapter(web, "firefly/allow") + .authorize(principal, "read", "resource", context).block(Duration.ofSeconds(3)); + assertNotNull(decision); + assertTrue(decision.granted()); + assertNotNull(captured.get()); + return captured.get(); + } + + @Test + void authenticatedIssuerIsProjectedWithoutClaimsOrAttributes() { + var input = authorize(ISSUER, Map.of("purpose", "ordinary-read")); + var subject = input.get("subject"); + assertEquals(ISSUER, subject.path("issuer").asText()); + var names = new HashSet(); + subject.fieldNames().forEachRemaining(names::add); + var expected = new HashSet<>(LEGACY_FIELDS); + expected.add("issuer"); + assertEquals(expected, names); + assertEquals("ordinary-staff", subject.path("subject").asText()); + assertEquals("source-tenant", subject.path("tenantId").asText()); + assertEquals("STAFF_READER", subject.path("authorities").get(0).asText()); + assertEquals("read", subject.path("scopes").get(0).asText()); + assertFalse(input.toString().contains("private-claim")); + assertFalse(input.toString().contains("private-attribute")); + } + + @Test + void requestContextCannotReplaceAuthenticatedIssuer() { + var input = authorize(ISSUER, Map.of("issuer", "https://different.example.test")); + assertEquals(ISSUER, input.path("subject").path("issuer").asText()); + assertEquals("https://different.example.test", input.path("context").path("issuer").asText()); + } + + @Test + void legacyPrincipalWithoutIssuerRetainsExactlyItsOriginalSubjectFields() { + var input = authorize(null, Map.of()); + var subject = input.path("subject"); + var names = new HashSet(); + subject.fieldNames().forEachRemaining(names::add); + assertEquals(LEGACY_FIELDS, names); + assertFalse(subject.has("issuer")); + } +} From 3503f250eb81309a86de2b357b6021c244b8f201 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 28 Oct 2026 11:57:48 -0700 Subject: [PATCH 41/44] Add bounded audit evidence with exact principal tenant keys --- .../kernel/audit/AuditActor.java | 36 ++ .../kernel/audit/AuditCheckpoint.java | 35 ++ .../kernel/audit/AuditEvent.java | 43 +++ .../kernel/audit/AuditEvidenceVerifier.java | 30 ++ .../kernel/audit/AuditRecord.java | 30 ++ .../kernel/audit/AuditRecordHasher.java | 96 ++++++ .../kernel/audit/AuditStream.java | 28 ++ .../kernel/audit/AuditValidation.java | 60 ++++ .../impl/Sha256AuditEvidenceVerifier.java | 50 +++ .../kernel/audit/package-info.java | 38 +++ .../audit/AuditEvidenceVerifierTest.java | 317 ++++++++++++++++++ 11 files changed, 763 insertions(+) create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditActor.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditCheckpoint.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditEvent.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditEvidenceVerifier.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditRecord.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditRecordHasher.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditStream.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditValidation.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/impl/Sha256AuditEvidenceVerifier.java create mode 100644 modules/kernel/src/main/java/org/fireflyframework/kernel/audit/package-info.java create mode 100644 modules/kernel/src/test/java/org/fireflyframework/kernel/audit/AuditEvidenceVerifierTest.java diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditActor.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditActor.java new file mode 100644 index 000000000..14024f9a1 --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditActor.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +import java.util.UUID; + +/** + * Exact claimed actor identity. Trusted authentication adapters must supply and verify it. + * The platform tenant is the exact authenticated key, independent of the resource tenant UUID. + * Staff realm and stable staff ID are either both present or both absent. + */ +public record AuditActor(String issuer, String subject, String clientId, String platformTenantId, + String staffRealm, UUID staffId) { + public AuditActor { + AuditValidation.text(issuer); + AuditValidation.text(subject); + AuditValidation.text(clientId); + AuditValidation.text(platformTenantId); + if ((staffRealm == null) != (staffId == null)) + throw new IllegalArgumentException("Incomplete audit staff identity"); + if (staffRealm != null) AuditValidation.text(staffRealm); + } +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditCheckpoint.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditCheckpoint.java new file mode 100644 index 000000000..2cedc2096 --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditCheckpoint.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +/** + * A checkpoint value, not proof of its custody or signature. Verification callers must obtain + * non-genesis checkpoints from an independently trusted source, not the range being checked. + */ +public record AuditCheckpoint(AuditStream stream, long lastSequence, String lastHash) { + private static final String GENESIS_HASH = "0".repeat(64); + + public AuditCheckpoint { + AuditValidation.required(stream); + AuditValidation.digest(lastHash); + if (lastSequence < 0 || (lastSequence == 0 && !GENESIS_HASH.equals(lastHash))) + throw new IllegalArgumentException("Invalid audit checkpoint sequence"); + } + + public static AuditCheckpoint genesis(AuditStream stream) { + return new AuditCheckpoint(stream, 0, GENESIS_HASH); + } +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditEvent.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditEvent.java new file mode 100644 index 000000000..9c3063bcf --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditEvent.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +import java.time.Instant; +import java.util.UUID; + +/** + * Bounded business evidence metadata, without arbitrary payloads or secrets. + * Identity, outcome, time and evidence digest require verification by the owning service. + * A null causation ID identifies a root event; other links remain explicit. + */ +public record AuditEvent(UUID eventId, AuditStream stream, AuditActor actor, String action, Outcome outcome, + UUID processId, UUID correlationId, UUID causationId, Instant occurredAt, + String evidenceDigest, String policyRevision) { + public enum Outcome { ATTEMPTED, ACCEPTED, REJECTED, FAILED, CANCELLED, COMPLETED, UNKNOWN } + + public AuditEvent { + AuditValidation.required(eventId); + AuditValidation.required(stream); + AuditValidation.required(actor); + AuditValidation.token(action); + AuditValidation.required(outcome); + AuditValidation.required(processId); + AuditValidation.required(correlationId); + AuditValidation.required(occurredAt); + AuditValidation.digest(evidenceDigest); + AuditValidation.token(policyRevision); + } +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditEvidenceVerifier.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditEvidenceVerifier.java new file mode 100644 index 000000000..32c8d37f9 --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditEvidenceVerifier.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +import java.util.List; + +/** Evidence integrity between trusted checkpoints; never an authorization or durability check. */ +public interface AuditEvidenceVerifier { + int MAX_RANGE_RECORDS = 1000; + + /** + * Verifies a complete, ordered range of at most {@value #MAX_RANGE_RECORDS} records. + * Event identity uniqueness outside this range must be enforced by the durable store. + * @throws IllegalArgumentException if the range is invalid or inconsistent + */ + void verify(AuditCheckpoint trustedStart, AuditCheckpoint trustedEnd, List records); +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditRecord.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditRecord.java new file mode 100644 index 000000000..bef3f3fe5 --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditRecord.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +import java.time.Instant; + +/** Unverified immutable evidence value; construction does not attest persistence or hash correctness. */ +public record AuditRecord(AuditEvent event, long sequence, Instant recordedAt, + String previousHash, String recordHash) { + public AuditRecord { + AuditValidation.required(event); + AuditValidation.required(recordedAt); + AuditValidation.digest(previousHash); + AuditValidation.digest(recordHash); + if (sequence < 1) throw new IllegalArgumentException("Invalid audit record sequence"); + } +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditRecordHasher.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditRecordHasher.java new file mode 100644 index 000000000..1311bc6fa --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditRecordHasher.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.UUID; + +/** + * Pure canonical SHA-256 evidence construction. Does not reserve sequence numbers, append data, + * sign a checkpoint, validate actor claims or establish an immutable storage boundary. + */ +public final class AuditRecordHasher { + private AuditRecordHasher() { } + + public static AuditRecord record(AuditCheckpoint previous, AuditEvent event, Instant recordedAt) { + AuditValidation.required(previous); + AuditValidation.required(event); + AuditValidation.required(recordedAt); + if (!previous.stream().equals(event.stream()) || previous.lastSequence() == Long.MAX_VALUE) + throw new IllegalArgumentException("Invalid audit stream or exhausted sequence"); + long sequence = previous.lastSequence() + 1; + try { + var bytes = new ByteArrayOutputStream(); + var out = new DataOutputStream(bytes); + text(out, "firefly.audit.record"); + out.writeInt(1); + uuid(out, event.stream().resourceTenantId()); + text(out, event.stream().service()); + text(out, event.stream().resourceType()); + uuid(out, event.stream().resourceId()); + out.writeLong(sequence); + text(out, previous.lastHash()); + instant(out, recordedAt); + uuid(out, event.eventId()); + text(out, event.actor().issuer()); + text(out, event.actor().subject()); + text(out, event.actor().clientId()); + text(out, event.actor().platformTenantId()); + out.writeBoolean(event.actor().staffId() != null); + if (event.actor().staffId() != null) { + text(out, event.actor().staffRealm()); + uuid(out, event.actor().staffId()); + } + text(out, event.action()); + text(out, event.outcome().name()); + uuid(out, event.processId()); + uuid(out, event.correlationId()); + out.writeBoolean(event.causationId() != null); + if (event.causationId() != null) uuid(out, event.causationId()); + instant(out, event.occurredAt()); + text(out, event.evidenceDigest()); + text(out, event.policyRevision()); + out.flush(); + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes.toByteArray())); + return new AuditRecord(event, sequence, recordedAt, previous.lastHash(), hash); + } catch (IOException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("Audit evidence encoding is unavailable", exception); + } + } + + private static void text(DataOutputStream out, String value) throws IOException { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + out.writeInt(bytes.length); + out.write(bytes); + } + + private static void uuid(DataOutputStream out, UUID value) throws IOException { + out.writeLong(value.getMostSignificantBits()); + out.writeLong(value.getLeastSignificantBits()); + } + + private static void instant(DataOutputStream out, Instant value) throws IOException { + out.writeLong(value.getEpochSecond()); + out.writeInt(value.getNano()); + } +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditStream.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditStream.java new file mode 100644 index 000000000..ea5cdc965 --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditStream.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +import java.util.UUID; + +/** An evidence stream scope, never a grant of access to its resource. */ +public record AuditStream(UUID resourceTenantId, String service, String resourceType, UUID resourceId) { + public AuditStream { + AuditValidation.required(resourceTenantId); + AuditValidation.token(service); + AuditValidation.token(resourceType); + AuditValidation.required(resourceId); + } +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditValidation.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditValidation.java new file mode 100644 index 000000000..ad79978f2 --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/AuditValidation.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.regex.Pattern; + +final class AuditValidation { + private static final Pattern TOKEN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}"); + private static final Pattern DIGEST = Pattern.compile("[0-9a-f]{64}"); + + private AuditValidation() { } + + static T required(T value) { + return Objects.requireNonNull(value, "Required audit field is absent"); + } + + static String token(String value) { + if (!TOKEN.matcher(required(value)).matches()) throw new IllegalArgumentException("Invalid audit token"); + return value; + } + + static String digest(String value) { + if (!DIGEST.matcher(required(value)).matches()) throw new IllegalArgumentException("Invalid audit digest"); + return value; + } + + static String text(String value) { + required(value); + if (value.isBlank() || value.length() > 2048) throw new IllegalArgumentException("Invalid audit text"); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (Character.isISOControl(c) || c == '\u2028' || c == '\u2029') + throw new IllegalArgumentException("Invalid audit text"); + if (Character.isHighSurrogate(c)) { + if (++i >= value.length() || !Character.isLowSurrogate(value.charAt(i))) + throw new IllegalArgumentException("Invalid audit text"); + } else if (Character.isLowSurrogate(c)) { + throw new IllegalArgumentException("Invalid audit text"); + } + } + if (value.getBytes(StandardCharsets.UTF_8).length > 2048) + throw new IllegalArgumentException("Invalid audit text"); + return value; + } +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/impl/Sha256AuditEvidenceVerifier.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/impl/Sha256AuditEvidenceVerifier.java new file mode 100644 index 000000000..7753b45dd --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/impl/Sha256AuditEvidenceVerifier.java @@ -0,0 +1,50 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit.impl; + +import org.fireflyframework.kernel.audit.AuditCheckpoint; +import org.fireflyframework.kernel.audit.AuditEvidenceVerifier; +import org.fireflyframework.kernel.audit.AuditRecord; +import org.fireflyframework.kernel.audit.AuditRecordHasher; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public final class Sha256AuditEvidenceVerifier implements AuditEvidenceVerifier { + @Override + public void verify(AuditCheckpoint trustedStart, AuditCheckpoint trustedEnd, List records) { + Objects.requireNonNull(trustedStart, "Required audit checkpoint is absent"); + Objects.requireNonNull(trustedEnd, "Required audit checkpoint is absent"); + Objects.requireNonNull(records, "Required audit range is absent"); + long count = trustedEnd.lastSequence() - trustedStart.lastSequence(); + if (!trustedStart.stream().equals(trustedEnd.stream()) || count < 0 || count > MAX_RANGE_RECORDS + || records.size() != count) + throw new IllegalArgumentException("Invalid audit range bounds"); + List snapshot = List.copyOf(records); + var identities = new HashSet(); + AuditCheckpoint previous = trustedStart; + for (AuditRecord record : snapshot) { + AuditRecord expected = AuditRecordHasher.record(previous, record.event(), record.recordedAt()); + if (record.sequence() != expected.sequence() || !record.previousHash().equals(expected.previousHash()) + || !record.recordHash().equals(expected.recordHash()) || !identities.add(record.event().eventId())) + throw new IllegalArgumentException("Audit range integrity mismatch"); + previous = new AuditCheckpoint(record.event().stream(), record.sequence(), record.recordHash()); + } + if (!previous.equals(trustedEnd)) throw new IllegalArgumentException("Audit terminal checkpoint mismatch"); + } +} diff --git a/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/package-info.java b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/package-info.java new file mode 100644 index 000000000..edf5b16f9 --- /dev/null +++ b/modules/kernel/src/main/java/org/fireflyframework/kernel/audit/package-info.java @@ -0,0 +1,38 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Immutable audit evidence values and bounded integrity verification. These types do not append + * records, authenticate actors, establish retention, or prove that business operations were audited. + * Owners must persist evidence atomically with each applicable local business effect and obtain + * verification checkpoints from independent custody. The existing security logging SPI is separate. + * + *

    Canonical record protocol 1 uses big-endian integers. Text is a four-byte UTF-8 byte count + * followed by the exact bytes; UUIDs are their two eight-byte words; instants are epoch seconds + * (eight bytes) and nanoseconds (four bytes). Optional values use one zero/one presence byte. + * No locale conversion, Unicode normalization, Java serialization or JSON rendering is involved. + * + *

    The ordered fields are domain text {@code firefly.audit.record}, protocol integer 1, + * stream tenant/service/resource type/resource ID, sequence, previous hash text, recorded instant, + * event ID, actor issuer/subject/client/platform tenant text, optional staff realm and staff ID, + * action, outcome enum name, process ID, correlation ID, optional causation ID, occurred instant, + * evidence digest text, and policy revision. SHA-256 of those bytes is lowercase hexadecimal. + * Enum renames and encoding changes require a new protocol and a compatibility design. + * + *

    Verification checks the requested range only. Database uniqueness must cover event identities + * beyond a range; independent business reconciliation must detect missing event production. A hash + * chain without independently retained checkpoints cannot detect wholesale history replacement. + */ +package org.fireflyframework.kernel.audit; diff --git a/modules/kernel/src/test/java/org/fireflyframework/kernel/audit/AuditEvidenceVerifierTest.java b/modules/kernel/src/test/java/org/fireflyframework/kernel/audit/AuditEvidenceVerifierTest.java new file mode 100644 index 000000000..958b478d4 --- /dev/null +++ b/modules/kernel/src/test/java/org/fireflyframework/kernel/audit/AuditEvidenceVerifierTest.java @@ -0,0 +1,317 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.kernel.audit; + +import org.fireflyframework.kernel.audit.impl.Sha256AuditEvidenceVerifier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class AuditEvidenceVerifierTest { + private static final String ZERO = "0".repeat(64); + private static final String CONTENT = "1".repeat(64); + private static final String PLATFORM_TENANT = "sandbox-platform"; + private static final Instant TIME = Instant.parse("2026-10-28T12:00:00.123456789Z"); + private final AuditStream stream = new AuditStream(id(1), "core-payments", "payment", id(2)); + private final AuditActor actor = new AuditActor("https://issuer.example.invalid", "subject", "terminal", + PLATFORM_TENANT, "employees", id(4)); + private final AuditEvidenceVerifier verifier = new Sha256AuditEvidenceVerifier(); + + private static UUID id(long n) { return new UUID(0, n); } + + private AuditEvent event(long id, String action) { + return new AuditEvent(id(id), stream, actor, action, AuditEvent.Outcome.COMPLETED, + id(10), id(11), id(12), TIME, CONTENT, "policy-7"); + } + + private AuditCheckpoint genesis() { return AuditCheckpoint.genesis(stream); } + private AuditCheckpoint after(AuditRecord record) { + return new AuditCheckpoint(record.event().stream(), record.sequence(), record.recordHash()); + } + + @Test + void acceptsCompleteRangeBetweenIndependentlySuppliedCheckpoints() { + var first = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME.plusNanos(1)); + var second = AuditRecordHasher.record(after(first), event(21, "PAYMENT_RECONCILE"), TIME.plusSeconds(2)); + assertDoesNotThrow(() -> verifier.verify(genesis(), after(second), List.of(first, second))); + } + + @Test + void acceptsAnEmptyRangeOnlyWhenBothCheckpointsAreIdentical() { + assertDoesNotThrow(() -> verifier.verify(genesis(), genesis(), List.of())); + var first = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME); + assertThrows(IllegalArgumentException.class, () -> verifier.verify(genesis(), after(first), List.of())); + } + + @Test + void detectsChangedContentAgainstTrustedEndEvenWhenReplacementHashIsValid() { + var original = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME); + var changed = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_CANCEL"), TIME); + assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), after(original), List.of(changed))); + } + + @Test + void refusesMissingTail() { + var first = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME); + var second = AuditRecordHasher.record(after(first), event(21, "PAYMENT_RECONCILE"), TIME); + assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), after(second), List.of(first))); + } + + @Test + void refusesReorderedRecords() { + var first = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME); + var second = AuditRecordHasher.record(after(first), event(21, "PAYMENT_RECONCILE"), TIME); + assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), after(second), List.of(second, first))); + } + + @Test + void refusesDuplicateEventIdentityEvenWithAValidChain() { + var first = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME); + var second = AuditRecordHasher.record(after(first), event(20, "PAYMENT_RECONCILE"), TIME); + assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), after(second), List.of(first, second))); + } + + @Test + void refusesAValidHashWithTheWrongPreviousLink() { + var first = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME); + var disconnected = AuditRecordHasher.record(new AuditCheckpoint(stream, 1, CONTENT), + event(21, "PAYMENT_RECONCILE"), TIME); + assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), after(disconnected), List.of(first, disconnected))); + } + + @Test + void refusesCorruptedRecordedHash() { + var first = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME); + var corrupted = new AuditRecord(first.event(), first.sequence(), first.recordedAt(), + first.previousHash(), CONTENT); + assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), after(corrupted), List.of(corrupted))); + } + + @Test + void bindsRecordingNanosecondsAndClientIdentity() { + var first = AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME); + assertNotEquals(first.recordHash(), + AuditRecordHasher.record(genesis(), first.event(), TIME.plusNanos(1)).recordHash()); + var alternate = new AuditActor(actor.issuer(), actor.subject(), "another-client", + actor.platformTenantId(), actor.staffRealm(), actor.staffId()); + var e = first.event(); + var changed = new AuditEvent(e.eventId(), e.stream(), alternate, e.action(), e.outcome(), + e.processId(), e.correlationId(), e.causationId(), e.occurredAt(), e.evidenceDigest(), e.policyRevision()); + assertNotEquals(first.recordHash(), AuditRecordHasher.record(genesis(), changed, TIME).recordHash()); + } + + @Test + void rejectsCrossStreamConstructionAndCheckpoints() { + var other = new AuditStream(id(90), stream.service(), stream.resourceType(), stream.resourceId()); + assertThrows(IllegalArgumentException.class, + () -> AuditRecordHasher.record(AuditCheckpoint.genesis(other), event(20, "PAYMENT_COMMIT"), TIME)); + assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), AuditCheckpoint.genesis(other), List.of())); + } + + @Test + void resourceTenantAndActorTenantNeedNotBeEqual() { + assertNotEquals(stream.resourceTenantId(), actor.platformTenantId()); + assertDoesNotThrow(() -> AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME)); + } + + @Test + void supportsExactMachineIdentityWithoutInventingStaffIdentity() { + assertDoesNotThrow(() -> new AuditActor(actor.issuer(), "worker", "batch", PLATFORM_TENANT, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new AuditActor(actor.issuer(), actor.subject(), actor.clientId(), PLATFORM_TENANT, "employees", null)); + } + + @Test + void rejectsMalformedUnicodeRatherThanReplacingItDuringHashing() { + assertThrows(IllegalArgumentException.class, + () -> new AuditActor(actor.issuer(), "bad\uD800", actor.clientId(), PLATFORM_TENANT, null, null)); + assertDoesNotThrow(() -> new AuditActor(actor.issuer(), "李 é \uD83D\uDE80", actor.clientId(), PLATFORM_TENANT, null, null)); + } + + @Test + void rejectsUnboundedOrControlBearingMetadataAndInvalidDigests() { + assertThrows(IllegalArgumentException.class, + () -> new AuditStream(id(1), "x".repeat(129), "payment", id(2))); + assertThrows(IllegalArgumentException.class, + () -> new AuditActor(actor.issuer(), "a\nb", actor.clientId(), PLATFORM_TENANT, null, null)); + assertThrows(IllegalArgumentException.class, () -> new AuditCheckpoint(stream, 1, "ABC")); + assertThrows(IllegalArgumentException.class, () -> new AuditCheckpoint(stream, -1, ZERO)); + assertThrows(IllegalArgumentException.class, () -> new AuditCheckpoint(stream, 0, CONTENT)); + } + + @Test + void refusesSequenceOverflowAndRangesAboveTheBound() { + assertThrows(IllegalArgumentException.class, + () -> AuditRecordHasher.record(new AuditCheckpoint(stream, Long.MAX_VALUE, CONTENT), + event(20, "PAYMENT_COMMIT"), TIME)); + assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), new AuditCheckpoint(stream, 1001, CONTENT), List.of())); + } + + @Test + void acceptsWindowFromNonGenesisCheckpointWithoutInventingEarlierCompleteness() { + var start = new AuditCheckpoint(stream, 800, CONTENT); + var record = AuditRecordHasher.record(start, event(20, "PAYMENT_COMMIT"), TIME); + assertDoesNotThrow(() -> verifier.verify(start, after(record), List.of(record))); + } + + @Test + void failureMessagesDoNotIncludeEventOrActorContents() { + var record = AuditRecordHasher.record(genesis(), event(20, "PRIVATE_ACTION"), TIME); + var error = assertThrows(IllegalArgumentException.class, + () -> verifier.verify(genesis(), new AuditCheckpoint(stream, 1, CONTENT), List.of(record))); + assertFalse(error.getMessage().contains("PRIVATE_ACTION")); + assertFalse(error.getMessage().contains(actor.subject())); + } + + @Test + void matchesTheIndependentlyEncoded471ByteProtocolVector() { + assertEquals("123bad017fcf66216bdb640821afaceff7116c9b3458e8173d080aca86e17385", + AuditRecordHasher.record(genesis(), event(20, "PAYMENT_COMMIT"), TIME).recordHash()); + } + + @Test + void lengthPrefixesDistinguishAdjacentVariableLengthIdentityFields() { + var a = new AuditActor(actor.issuer(), "ab", "c", PLATFORM_TENANT, null, null); + var b = new AuditActor(actor.issuer(), "a", "bc", PLATFORM_TENANT, null, null); + var e = event(20, "PAYMENT_COMMIT"); + assertNotEquals(hash(AuditEvent.class.cast(replace(e, "actor", a))), + hash(AuditEvent.class.cast(replace(e, "actor", b)))); + } + + @Test + void absentCausationAndZeroUuidRemainDistinct() { + var e = event(20, "PAYMENT_COMMIT"); + assertNotEquals(hash(AuditEvent.class.cast(replace(e, "causationId", null))), + hash(AuditEvent.class.cast(replace(e, "causationId", id(0))))); + } + + @Test + void enforcesUtf8ByteBoundWithoutNormalizingIdentity() { + assertThrows(IllegalArgumentException.class, + () -> new AuditActor(actor.issuer(), "李".repeat(683), "client", PLATFORM_TENANT, null, null)); + var composed = new AuditActor(actor.issuer(), "é", "client", PLATFORM_TENANT, null, null); + var decomposed = new AuditActor(actor.issuer(), "e\u0301", "client", PLATFORM_TENANT, null, null); + var e = event(20, "PAYMENT_COMMIT"); + assertNotEquals(hash(AuditEvent.class.cast(replace(e, "actor", composed))), + hash(AuditEvent.class.cast(replace(e, "actor", decomposed)))); + } + + @Test + void preservesNonUuidAuthenticatedPlatformTenantKeysExactly() { + String tenant = "External:Tenant A"; + var value = new AuditActor(actor.issuer(), actor.subject(), actor.clientId(), tenant, null, null); + assertEquals(tenant, value.platformTenantId()); + assertEquals(id(1), stream.resourceTenantId()); + } + + @Test + void platformTenantKeysRejectBlankControlsMalformedUnicodeAndExcessUtf8() { + for (String tenant : List.of(" ", "tenant\n", "bad\uD800", "李".repeat(683))) { + assertThrows(IllegalArgumentException.class, + () -> new AuditActor(actor.issuer(), actor.subject(), actor.clientId(), tenant, null, null)); + } + } + + @Test + void uuidLookingAuthenticatedTenantKeysAreNotCanonicalized() { + String upper = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"; + var a = new AuditActor(actor.issuer(), actor.subject(), actor.clientId(), upper, null, null); + var b = new AuditActor(actor.issuer(), actor.subject(), actor.clientId(), upper.toLowerCase(java.util.Locale.ROOT), null, null); + assertEquals(upper, a.platformTenantId()); + var e = event(20, "PAYMENT_COMMIT"); + assertNotEquals(hash(AuditEvent.class.cast(replace(e, "actor", a))), + hash(AuditEvent.class.cast(replace(e, "actor", b)))); + } + + @TestFactory + Stream everyEventComponentAffectsEvidence() { + var original = event(20, "PAYMENT_COMMIT"); + var alternatives = Map.ofEntries( + Map.entry("eventId", id(100)), + Map.entry("stream", new AuditStream(id(100), stream.service(), stream.resourceType(), stream.resourceId())), + Map.entry("actor", new AuditActor(actor.issuer(), "other", actor.clientId(), PLATFORM_TENANT, "employees", id(4))), + Map.entry("action", "PAYMENT_CANCEL"), Map.entry("outcome", AuditEvent.Outcome.UNKNOWN), + Map.entry("processId", id(100)), Map.entry("correlationId", id(100)), + Map.entry("causationId", id(100)), Map.entry("occurredAt", TIME.plusNanos(1)), + Map.entry("evidenceDigest", "2".repeat(64)), Map.entry("policyRevision", "policy-8")); + assertEquals(original.getClass().getRecordComponents().length, alternatives.size()); + return alternatives.entrySet().stream().map(entry -> DynamicTest.dynamicTest(entry.getKey(), + () -> assertNotEquals(hash(original), hash(AuditEvent.class.cast(replace(original, entry.getKey(), entry.getValue())))))); + } + + @TestFactory + Stream everyActorComponentAffectsEvidence() { + var e = event(20, "PAYMENT_COMMIT"); + var alternatives = Map.of("issuer", "https://other.example.invalid", "subject", "other", + "clientId", "other", "platformTenantId", "another-platform-tenant", "staffRealm", "contractors", "staffId", id(100)); + assertEquals(actor.getClass().getRecordComponents().length, alternatives.size()); + return alternatives.entrySet().stream().map(entry -> DynamicTest.dynamicTest(entry.getKey(), () -> { + var changed = AuditActor.class.cast(replace(actor, entry.getKey(), entry.getValue())); + assertNotEquals(hash(e), hash(AuditEvent.class.cast(replace(e, "actor", changed)))); + })); + } + + @TestFactory + Stream everyStreamComponentAffectsEvidence() { + var e = event(20, "PAYMENT_COMMIT"); + var alternatives = Map.of("resourceTenantId", id(100), "service", "other-core", + "resourceType", "other-type", "resourceId", id(100)); + assertEquals(stream.getClass().getRecordComponents().length, alternatives.size()); + return alternatives.entrySet().stream().map(entry -> DynamicTest.dynamicTest(entry.getKey(), () -> { + var changed = AuditStream.class.cast(replace(stream, entry.getKey(), entry.getValue())); + assertNotEquals(hash(e), hash(AuditEvent.class.cast(replace(e, "stream", changed)))); + })); + } + + private String hash(AuditEvent event) { + return AuditRecordHasher.record(AuditCheckpoint.genesis(event.stream()), event, TIME).recordHash(); + } + + private Object replace(Record original, String name, Object value) { + try { + var components = original.getClass().getRecordComponents(); + var types = new Class[components.length]; + var arguments = new Object[components.length]; + boolean found = false; + for (int i = 0; i < components.length; i++) { + types[i] = components[i].getType(); + boolean selected = components[i].getName().equals(name); + arguments[i] = selected ? value : components[i].getAccessor().invoke(original); + found |= selected; + } + assertTrue(found); + return original.getClass().getDeclaredConstructor(types).newInstance(arguments); + } catch (ReflectiveOperationException exception) { + throw new AssertionError("Cannot construct synthetic audit vector", exception); + } + } +} From 85f5f95eaed86c06d7c64c78882220eac7031232 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 28 Oct 2026 13:53:47 -0700 Subject: [PATCH 42/44] Append immutable audit evidence in the owning R2DBC transaction --- docs/immutable-local-audit.md | 37 ++++ .../core/audit/AuditAppendAuthority.java | 33 +++ .../audit/AuditNativeContextRepository.java | 33 +++ .../core/audit/AuditRows.java | 45 ++++ .../core/audit/LocalAuditRepository.java | 28 +++ .../audit/TransactionalAuditAppender.java | 27 +++ .../impl/SpringDataLocalAuditRepository.java | 67 ++++++ .../SpringDataTransactionalAuditAppender.java | 75 +++++++ .../transaction/BoundedR2dbcResources.java | 1 + .../OwnedLeaseConnectionFactory.java | 24 +++ .../optional/immutable-audit/postgresql.sql | 200 ++++++++++++++++++ .../core/audit/AuditTestFixture.java | 72 +++++++ .../core/audit/LocalAuditRepositoryTest.java | 55 +++++ .../audit/TransactionalAuditAppenderTest.java | 63 ++++++ .../BoundedAuditOwnershipTest.java | 63 ++++++ 15 files changed, 823 insertions(+) create mode 100644 docs/immutable-local-audit.md create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditAppendAuthority.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditNativeContextRepository.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditRows.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/audit/LocalAuditRepository.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/audit/TransactionalAuditAppender.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/audit/impl/SpringDataLocalAuditRepository.java create mode 100644 modules/r2dbc/src/main/java/org/fireflyframework/core/audit/impl/SpringDataTransactionalAuditAppender.java create mode 100644 modules/r2dbc/src/main/resources/db/optional/immutable-audit/postgresql.sql create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/audit/AuditTestFixture.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/audit/LocalAuditRepositoryTest.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/audit/TransactionalAuditAppenderTest.java create mode 100644 modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedAuditOwnershipTest.java diff --git a/docs/immutable-local-audit.md b/docs/immutable-local-audit.md new file mode 100644 index 000000000..5bbc15947 --- /dev/null +++ b/docs/immutable-local-audit.md @@ -0,0 +1,37 @@ +# Local transactional immutable audit and permanent export intent + +This opt-in capability appends canonical kernel audit values and a permanent outbox intent inside the owning command's existing local R2DBC transaction. It creates no Spring bean, transaction, connection pool, default schema, security grant or background subscriber. The best-effort security audit logging SPI remains unchanged. + +## Construct on the owning resource graph + +The owner constructs one stable `OwnedLeaseConnectionFactory`, `DatabaseClient`, `R2dbcEntityTemplate` and `R2dbcTransactionManager`, verifies them with `BoundedR2dbcResources.verify`, and uses the existing `DefaultBoundedRootTransaction`. Pass those exact objects to `SpringDataTransactionalAuditAppender`, together with a mandatory genuine `AuditAppendAuthority` and the **same Clock and LongSupplier instances used for the original operation budget**. Do not wrap or replace a caller's client/template, converters or statement filters. + +The appender's `append(scope, event)` must be composed into the owning root work publisher, before that publisher completes. It proves the private active invocation and exact RootTransactionScope/resource graph before admission, then uses the transaction-bound same connection. It never invokes a second root or generic joining TransactionalOperator. Ordinary ambient transactions, stale/foreign scopes and mismatched graphs refuse. Narrowing the genuine original scope preserves its identity. + +The authority port must resolve the authenticated current actor, authorize the resource and action, and verify the event's outcome, process, evidence digest and policy facts. Kernel values are claims, not authentication. The appender compares the complete actor and stream against the permit at admission and again before native completion. There is no default or successful unavailable provider. The actor's platform tenant key is an exact String namespace; the resource tenant remains a separate UUID. Do not invent a UUID mapping or equate those tenants. + +## Persistence and replay + +Every public `LocalAuditRepository` operation also requires the genuine RootTransactionScope and the same verified factory/client/template/manager graph; it cannot fall back to an ordinary ambient transaction. It exposes no inherited general CRUD API. Its mapped statement helpers are private. + +`SpringDataLocalAuditRepository` uses the owning template's genuine mapping strategy/converter and StatementMapper for ordinary inserts and head CAS. Every insert/update must observe exactly one actual affected row. Zero, multiple and missing observations refuse. It stores every canonical kernel field, including separate Instant seconds/nanos, with no serializer-dependent JSON. Event uniqueness is `(resource_tenant_id,event_id)` across all streams in that tenant. The stream head CAS includes the whole scope, expected sequence and previous hash. + +The immutable event and its immutable `AUDIT_EXPORT` intent are inserted in the same local transaction as the head and business work. The next append links the prior hash. Exact replay reads the original full event, verifies its canonical record hash and full permanent outbox, and returns the original record without changing its timestamp or sequence. Different content under the same event ID refuses. A duplicate database error propagates without reading or continuing in the aborted transaction. A mandatory append failure marks the genuine owner transaction rollback-only, so catching a logical append refusal cannot commit the business effect. Marking performs no SQL and never clears rollback-only. + +The API returns `ProvisionalAppend`, which is not a durability receipt. The owner must independently inspect its original RootCompletion: acknowledged commit, completed boundary and complete cleanup are separate required facts. A caught refusal can yield a completed rollback boundary with **no commit**; returning a work value does not change that fact. RootTransactionException can also retain acknowledged-commit facts despite withholding a value or incomplete cleanup. Do not infer no-effect or replay a business effect from an exception alone. + +The library cannot detect an owner that omits the mandatory audit call. Explicit owner integration and native business/audit effect capture remain adoption gates. External export/delivery, mutable retry leases and ACK state must be separate from permanent event/outbox rows; no delivery worker is supplied here. + +## Explicit PostgreSQL contract + +`modules/r2dbc/src/main/resources/db/optional/immutable-audit/postgresql.sql` is a standalone explicit transaction, outside default Flyway locations and versioned naming. It must be deliberately installed once by a separately delegated installer. It neither baselines/repairs history nor assumes any owning business table. There is no automatic Flyway bean. Provision exact NOINHERIT/NOSUPERUSER/NOBYPASSRLS/NOCREATEDB/NOCREATEROLE/NOREPLICATION roles beforehand: non-login `firefly_audit_guard`; login `firefly_audit_writer` and `firefly_audit_reader`. The installer must be able to SET the guard and hold public CREATE WITH GRANT OPTION. The script removes its temporary guard public CREATE grant before commit. + +The owning business pool uses `firefly_audit_writer`; the deployment must deliberately grant that role its own core's required business privileges. The script grants no business-table permission. The writer cannot own audit tables or use the private schema. Ordinary reader grants cannot mutate originals; reader RLS is denied by default until a separately admitted export/read adapter is implemented. No general read API is supplied. + +The concrete native repository calls fixed technical functions on the same transaction. Enter binds actual session/xid/backend, nonce, event, whole resource scope and full admitted actor, serializes scoped event/stream mechanics, and returns the real database recording clock and a cap at most five seconds. The appender narrows its original paired budget with a one-millisecond completion margin; no new deadline is granted. Native completion and a deferred constraint require the immutable event, complete permanent intent, current head and native-time linkage. Multiple sequential append contexts can exist within one owner transaction, each completed separately and still checked at the original boundary. The fixed native clock/functions do not authenticate their actor parameters or establish a genuine owner permit. + +Database primary/foreign keys enforce tenant-scoped identity, unique stream sequence, immutable predecessor links and current head linkage. UPDATE/DELETE triggers and grants protect originals for ordinary runtime roles. The adapter computes/rechecks canonical kernel hashes; this SQL contract does not independently reimplement the canonical hash protocol. Privileged storage administrators remain outside ordinary-role protection. Actual PostgreSQL mapping, constraints, privileges, rollback and restart behavior require the separately approved native fixture; source tests are not that evidence. + +## Remaining operational and compliance work + +No checkpoint custody, authenticated signing, WORM storage, retention/hold/disposition engine, trusted reference-clock operations, external export acknowledgment or restore evidence is supplied. There is no invented global DORA retention period and no compliance certification. Full framework CQRS/saga/TCC/batch lifecycle integration and explicit per-core/process adoption remain separate gates. Existing owner journals must retain their current contracts until a real integration is qualified. diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditAppendAuthority.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditAppendAuthority.java new file mode 100644 index 000000000..9ea4ef8cc --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditAppendAuthority.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit; + +import java.time.Instant; +import java.util.Objects; +import org.fireflyframework.kernel.audit.*; +import reactor.core.publisher.Mono; + +/** + * Required genuine owning security/admission adapter, never derived from event claims alone. + * Must authenticate the current actor, authorize the resource/action and verify outcome/process/ + * evidence/policy facts. Resource and platform tenants may differ. No default implementation exists. + */ +public interface AuditAppendAuthority { + record Permit(AuditStream stream,AuditActor actor,Instant validUntil) { + public Permit{Objects.requireNonNull(stream);Objects.requireNonNull(actor);Objects.requireNonNull(validUntil);} + } + Mono requireCurrent(AuditEvent event); +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditNativeContextRepository.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditNativeContextRepository.java new file mode 100644 index 000000000..cc9c6d480 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditNativeContextRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit; + +import java.time.OffsetDateTime; +import java.util.UUID; +import org.springframework.data.repository.*; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.query.Param; +import reactor.core.publisher.Mono; + +/** Explicit construction only; no automatic repository bean or fallback context. */ +@NoRepositoryBean +public interface AuditNativeContextRepository extends Repository { + record Stamp(UUID contextNonce,UUID eventId,Long dbNowMillis,Long untilMillis) { } + @Query("SELECT * FROM public.firefly_audit_enter(:nonce,:tenant,:service,:type,:resource,:event,:issuer,:subject,:client,:platform,:realm,:staff,:until)") + Mono enter(@Param("nonce")UUID nonce,@Param("tenant")UUID tenant,@Param("service")String service,@Param("type")String type,@Param("resource")UUID resource,@Param("event")UUID event,@Param("issuer")String issuer,@Param("subject")String subject,@Param("client")String client,@Param("platform")String platform,@Param("realm")String realm,@Param("staff")UUID staff,@Param("until")OffsetDateTime until); + @Query("SELECT public.firefly_audit_complete(:nonce,:sequence,:hash) AS nonce") + Mono complete(@Param("nonce")UUID nonce,@Param("sequence")long sequence,@Param("hash")String hash); +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditRows.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditRows.java new file mode 100644 index 000000000..6c3885440 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/AuditRows.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit; + +import java.time.Instant; +import java.util.UUID; +import org.fireflyframework.kernel.audit.*; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.annotation.Id; + +/** Exact immutable storage projection of kernel values; no authorization claims are created. */ +public final class AuditRows { + private AuditRows() { } + @Table(name="audit_head",schema="firefly_audit") + public record Head(UUID resourceTenantId,String service,String resourceType,UUID resourceId,long lastSequence,String lastHash) { + public AuditCheckpoint checkpoint(){return new AuditCheckpoint(new AuditStream(resourceTenantId,service,resourceType,resourceId),lastSequence,lastHash);} + public static Head from(AuditCheckpoint c){return new Head(c.stream().resourceTenantId(),c.stream().service(),c.stream().resourceType(),c.stream().resourceId(),c.lastSequence(),c.lastHash());} + } + @Table(name="audit_event",schema="firefly_audit") + public record Event(UUID resourceTenantId,String service,String resourceType,UUID resourceId,@Id UUID eventId, + long sequence,String previousHash,String recordHash,long recordedSeconds,int recordedNanos, + String actorIssuer,String actorSubject,String actorClientId,String actorPlatformTenantId,String actorStaffRealm,UUID actorStaffId, + String action,String outcome,UUID processId,UUID correlationId,UUID causationId,long occurredSeconds,int occurredNanos,String evidenceDigest,String policyRevision) { + public static Event from(AuditRecord r){var e=r.event();var s=e.stream();var a=e.actor();return new Event(s.resourceTenantId(),s.service(),s.resourceType(),s.resourceId(),e.eventId(),r.sequence(),r.previousHash(),r.recordHash(),r.recordedAt().getEpochSecond(),r.recordedAt().getNano(),a.issuer(),a.subject(),a.clientId(),a.platformTenantId(),a.staffRealm(),a.staffId(),e.action(),e.outcome().name(),e.processId(),e.correlationId(),e.causationId(),e.occurredAt().getEpochSecond(),e.occurredAt().getNano(),e.evidenceDigest(),e.policyRevision());} + public AuditRecord record(){var event=new AuditEvent(eventId,new AuditStream(resourceTenantId,service,resourceType,resourceId),new AuditActor(actorIssuer,actorSubject,actorClientId,actorPlatformTenantId,actorStaffRealm,actorStaffId),action,AuditEvent.Outcome.valueOf(outcome),processId,correlationId,causationId,Instant.ofEpochSecond(occurredSeconds,occurredNanos),evidenceDigest,policyRevision);var record=new AuditRecord(event,sequence,Instant.ofEpochSecond(recordedSeconds,recordedNanos),previousHash,recordHash);var expected=AuditRecordHasher.record(new AuditCheckpoint(event.stream(),sequence-1,previousHash),event,record.recordedAt());if(!expected.equals(record))throw new IllegalStateException("Audit stored record integrity mismatch");return record;} + } + /** Permanent export intent; mutable delivery leases/ACKs must live in separate state. */ + @Table(name="audit_outbox",schema="firefly_audit") + public record Outbox(UUID resourceTenantId,UUID eventId,String service,String resourceType,UUID resourceId,long sequence,String recordHash,String intent) { + public static Outbox from(AuditRecord r){var s=r.event().stream();return new Outbox(s.resourceTenantId(),r.event().eventId(),s.service(),s.resourceType(),s.resourceId(),r.sequence(),r.recordHash(),"AUDIT_EXPORT");} + } +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/LocalAuditRepository.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/LocalAuditRepository.java new file mode 100644 index 000000000..acec81b3e --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/LocalAuditRepository.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit; + +import java.util.UUID; +import org.fireflyframework.core.transaction.RootTransactionScope; +import org.fireflyframework.kernel.audit.*; +import reactor.core.publisher.Mono; + +/** Local mapped operations, only inside the owner's admitted transaction. */ +public interface LocalAuditRepository { + Mono head(RootTransactionScope scope,AuditStream stream); + Mono find(RootTransactionScope scope,UUID resourceTenantId,UUID eventId); + Mono append(RootTransactionScope scope,AuditCheckpoint expected,AuditRecord record); +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/TransactionalAuditAppender.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/TransactionalAuditAppender.java new file mode 100644 index 000000000..1ea986ffb --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/TransactionalAuditAppender.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit; + +import org.fireflyframework.core.transaction.RootTransactionScope; +import org.fireflyframework.kernel.audit.*; +import reactor.core.publisher.Mono; + +/** Mandatory append inside an existing owner root; never starts a transaction. */ +public interface TransactionalAuditAppender { + /** Provisional until the owner obtains its own successful RootCompletion/cleanup facts. */ + record ProvisionalAppend(AuditRecord record,boolean replay) { } + Mono append(RootTransactionScope scope,AuditEvent event); +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/impl/SpringDataLocalAuditRepository.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/impl/SpringDataLocalAuditRepository.java new file mode 100644 index 000000000..e537b1c6f --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/impl/SpringDataLocalAuditRepository.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit.impl; + +import java.util.Objects; +import java.util.UUID; +import org.fireflyframework.core.audit.*; +import org.fireflyframework.core.transaction.*; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.r2dbc.core.DatabaseClient; +import org.fireflyframework.kernel.audit.*; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.core.StatementMapper; +import org.springframework.data.relational.core.query.*; +import reactor.core.publisher.Mono; + +/** Actual mapped statements/counts. Failures propagate; never continue on an aborted duplicate. */ +public final class SpringDataLocalAuditRepository implements LocalAuditRepository { + private final R2dbcEntityTemplate template; + private final BoundedR2dbcResources resources; + public SpringDataLocalAuditRepository(BoundedR2dbcResources resources,DatabaseClient client,R2dbcEntityTemplate template,R2dbcTransactionManager manager){this.resources=BoundedR2dbcResources.verify(Objects.requireNonNull(resources).factory(),client,template,manager);this.template=template;} + @Override public Mono head(RootTransactionScope owner,AuditStream stream){return transaction(owner,()->template.select(AuditRows.Head.class).matching(Query.query(scope(stream))).one().map(AuditRows.Head::checkpoint).defaultIfEmpty(AuditCheckpoint.genesis(stream)));} + @Override public Mono find(RootTransactionScope owner,UUID tenant,UUID event){return transaction(owner,()->{ + var query=Query.query(Criteria.where("resourceTenantId").is(tenant).and("eventId").is(event)); + return template.select(AuditRows.Event.class).matching(query).one().map(AuditRows.Event::record).flatMap(record->template.select(AuditRows.Outbox.class).matching(query).one() + .filter(AuditRows.Outbox.from(record)::equals).switchIfEmpty(Mono.error(failure())).thenReturn(record)); + });} + @Override public Mono append(RootTransactionScope owner,AuditCheckpoint expected,AuditRecord record){return transaction(owner,()->{ + if(!AuditRecordHasher.record(expected,record.event(),record.recordedAt()).equals(record))return Mono.error(failure()); + var next=new AuditCheckpoint(record.event().stream(),record.sequence(),record.recordHash()); + Mono head; + if(expected.lastSequence()==0)head=insert(AuditRows.Head.from(next)); + else{ + var strategy=template.getDataAccessStrategy();var mapper=strategy.getStatementMapper().forType(AuditRows.Head.class); + var criteria=scope(expected.stream()).and("lastSequence").is(expected.lastSequence()).and("lastHash").is(expected.lastHash()); + var update=mapper.createUpdate(strategy.getTableName(AuditRows.Head.class),Update.update("lastSequence",next.lastSequence()).set("lastHash",next.lastHash())).withCriteria(criteria); + head=exactlyOne(template.getDatabaseClient().sql(mapper.getMappedObject(update)).fetch().rowsUpdated()); + } + return head.then(insert(AuditRows.Event.from(record))).then(insert(AuditRows.Outbox.from(record))) + .then(find(owner,record.event().stream().resourceTenantId(),record.event().eventId())) + .filter(record::equals).switchIfEmpty(Mono.error(failure())); + });} + private Mono insert(T row){ + var strategy=template.getDataAccessStrategy();var mapper=strategy.getStatementMapper().forType(row.getClass()); + StatementMapper.InsertSpec insert=mapper.createInsert(strategy.getTableName(row.getClass())); + for(var entry:strategy.getOutboundRow(row).entrySet())insert=insert.withColumn(entry.getKey(),entry.getValue()); + return exactlyOne(template.getDatabaseClient().sql(mapper.getMappedObject(insert)).fetch().rowsUpdated()); + } + private static Mono exactlyOne(Mono count){return count.switchIfEmpty(Mono.error(failure())).flatMap(n->n==1?Mono.empty():Mono.error(failure()));} + private Mono transaction(RootTransactionScope scope,java.util.function.Supplier> work){return resources.factory().requireBoundScope(resources,scope).then(Mono.defer(work)) + .onErrorResume(error->resources.factory().markBoundScopeRollbackOnly(resources,scope).onErrorResume(markFailure->Mono.error(error)).then(Mono.error(error)));} + private static Criteria scope(AuditStream s){return Criteria.where("resourceTenantId").is(s.resourceTenantId()).and("service").is(s.service()).and("resourceType").is(s.resourceType()).and("resourceId").is(s.resourceId());} + private static IllegalStateException failure(){return new IllegalStateException("Exact local audit storage observation required");} +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/impl/SpringDataTransactionalAuditAppender.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/impl/SpringDataTransactionalAuditAppender.java new file mode 100644 index 000000000..06cdf5655 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/audit/impl/SpringDataTransactionalAuditAppender.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit.impl; + +import java.time.*; +import java.util.*; +import java.util.function.*; +import org.fireflyframework.core.audit.*; +import org.fireflyframework.core.transaction.*; +import org.fireflyframework.kernel.audit.*; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.repository.support.R2dbcRepositoryFactory; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.r2dbc.core.DatabaseClient; +import reactor.core.publisher.Mono; + +/** Opt-in real mapped append/native clock; never creates an inner root or treats claims as grants. */ +public final class SpringDataTransactionalAuditAppender implements TransactionalAuditAppender { + private final BoundedR2dbcResources resources; + private final LocalAuditRepository repository; + private final AuditNativeContextRepository nativeContext; + private final AuditAppendAuthority authority; + private final Clock clock;private final LongSupplier nanos;private final Supplier nonces; + public SpringDataTransactionalAuditAppender(BoundedR2dbcResources resources,DatabaseClient client,R2dbcEntityTemplate template,R2dbcTransactionManager manager,AuditAppendAuthority authority,Clock clock,LongSupplier nanos,Supplier nonces){ + this.resources=BoundedR2dbcResources.verify(Objects.requireNonNull(resources).factory(),client,template,manager); + this.authority=Objects.requireNonNull(authority);this.clock=Objects.requireNonNull(clock);this.nanos=Objects.requireNonNull(nanos);this.nonces=Objects.requireNonNull(nonces); + repository=new SpringDataLocalAuditRepository(this.resources,client,template,manager);nativeContext=new R2dbcRepositoryFactory(template).getRepository(AuditNativeContextRepository.class); + } + @Override public Mono append(RootTransactionScope scope,AuditEvent event){return Mono.defer(()->resources.factory().requireBoundScope(resources,scope).then(Mono.defer(()->{ + Objects.requireNonNull(event);scope.remaining(); + return current(event,scope).flatMap(permit->{ + UUID nonce=Objects.requireNonNull(nonces.get());if(nonce.equals(new UUID(0,0)))return Mono.error(failure()); + Instant now=clock.instant();Instant until=Collections.min(List.of(permit.validUntil(),now.plus(scope.remaining()),now.plusSeconds(5))); + long beforeNative=nanos.getAsLong();var s=permit.stream();var a=permit.actor(); + return nativeContext.enter(nonce,s.resourceTenantId(),s.service(),s.resourceType(),s.resourceId(),event.eventId(),a.issuer(),a.subject(),a.clientId(),a.platformTenantId(),a.staffRealm(),a.staffId(),OffsetDateTime.ofInstant(until,ZoneOffset.UTC)) + .switchIfEmpty(Mono.error(failure())).timeout(scope.remaining()).flatMap(stamp->{ + if(!nonce.equals(stamp.contextNonce())||!event.eventId().equals(stamp.eventId())||stamp.dbNowMillis()==null||stamp.untilMillis()==null + ||stamp.dbNowMillis()<=0||stamp.untilMillis()>until.toEpochMilli()||stamp.untilMillis()-1<=stamp.dbNowMillis())return Mono.error(failure()); + var cap=R2dbcOperationBudget.observed(Instant.ofEpochMilli(stamp.dbNowMillis()),Instant.ofEpochMilli(stamp.untilMillis()-1),beforeNative,Duration.ofSeconds(5),clock,nanos);scope.narrow(cap); + return repository.find(scope,s.resourceTenantId(),event.eventId()).flatMap(record->{ + if(!record.event().equals(event))return Mono.error(failure()); + return repository.head(scope,s).filter(head->head.lastSequence()>=record.sequence()&&(head.lastSequence()!=record.sequence()||head.lastHash().equals(record.recordHash()))) + .switchIfEmpty(Mono.error(failure())).thenReturn(new ProvisionalAppend(record,true)); + }).switchIfEmpty(Mono.defer(()->repository.head(scope,s).flatMap(head->repository.append(scope,head,AuditRecordHasher.record(head,event,Instant.ofEpochMilli(stamp.dbNowMillis())))).map(record->new ProvisionalAppend(record,false)))) + .flatMap(provisional->current(event,scope).flatMap(current->{ + if(current.validUntil().isBefore(permit.validUntil())){ + long beforeCurrent=nanos.getAsLong();Instant observed=clock.instant(); + Instant currentUntil=Collections.min(List.of(current.validUntil(),observed.plus(scope.remaining()),observed.plusSeconds(5))); + var narrowed=R2dbcOperationBudget.observed(observed,currentUntil,beforeCurrent,Duration.ofSeconds(5),clock,nanos);scope.narrow(narrowed); + } + return resources.factory().requireBoundScope(resources,scope).then(nativeContext.complete(nonce,provisional.record().sequence(),provisional.record().recordHash())) + .filter(nonce::equals).switchIfEmpty(Mono.error(failure())).timeout(scope.remaining()).map(done->{scope.remaining();return provisional;}); + })); + }); + }); + }).onErrorResume(error->resources.factory().markBoundScopeRollbackOnly(resources,scope) + .onErrorResume(markFailure->Mono.error(error)).then(Mono.error(error)))));} + private Mono current(AuditEvent event,RootTransactionScope scope){return Mono.defer(()->authority.requireCurrent(event)).switchIfEmpty(Mono.error(failure())).timeout(scope.remaining()).map(permit->{ + if(!permit.stream().equals(event.stream())||!permit.actor().equals(event.actor())||!permit.validUntil().isAfter(clock.instant()))throw failure();return permit; + });} + private static IllegalStateException failure(){return new IllegalStateException("Current admitted local audit append required");} +} diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedR2dbcResources.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedR2dbcResources.java index bbb51d34f..105266b20 100644 --- a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedR2dbcResources.java +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/BoundedR2dbcResources.java @@ -22,6 +22,7 @@ public static BoundedR2dbcResources verify(OwnedLeaseConnectionFactory factory,D return new BoundedR2dbcResources(factory,client,template,manager); } boolean current(){return client.getConnectionFactory()==factory&&template.getDatabaseClient()==client&&manager.getConnectionFactory()==factory;} + boolean sameGraph(BoundedR2dbcResources other){return other!=null&&factory==other.factory&&client==other.client&&template==other.template&&manager==other.manager;} public OwnedLeaseConnectionFactory factory(){return factory;} public R2dbcTransactionManager manager(){return manager;} } diff --git a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/OwnedLeaseConnectionFactory.java b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/OwnedLeaseConnectionFactory.java index 4afe8f1b9..fdea1850c 100644 --- a/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/OwnedLeaseConnectionFactory.java +++ b/modules/r2dbc/src/main/java/org/fireflyframework/core/transaction/OwnedLeaseConnectionFactory.java @@ -29,6 +29,30 @@ public final class OwnedLeaseConnectionFactory extends DelegatingConnectionFacto return Mono.from(super.create()).map(scope::own); });} + /** Proves an append participates in this exact, still-active owner invocation and resource graph. */ + public Mono requireBoundScope(BoundedR2dbcResources resources,RootTransactionScope expected){ + return Mono.deferContextual(context->{ + Object active=context.getOrDefault(INVOCATION,null); + if(!(active instanceof Invocation scope)||active!=expected||scope.factory!=this||resources==null + ||!resources.sameGraph(scope.resources)||!resources.current()||!scope.resources.current() + ||scope.transaction.get()==null||scope.transaction.get().isCompleted()) + return Mono.error(new IllegalStateException("Exact active bounded audit transaction required")); + scope.remaining(); + return TransactionSynchronizationManager.forCurrentTransaction() + .filter(manager->manager.isActualTransactionActive()&&manager.hasResource(this)) + .switchIfEmpty(Mono.error(new IllegalStateException("Exact active bounded audit transaction required"))) + .onErrorMap(NoTransactionException.class,error->new IllegalStateException("Exact active bounded audit transaction required")) + .then(); + }); + } + + /** Mandatory append failure cannot be converted into a committing business result by error recovery. */ + public Mono markBoundScopeRollbackOnly(BoundedR2dbcResources resources,RootTransactionScope expected){ + return requireBoundScope(resources,expected).then(Mono.deferContextual(context->{ + ((Invocation)context.get(INVOCATION)).transaction.get().setRollbackOnly();return Mono.empty(); + })); + } + /** Execution bridge used by the opt-in service; the verified graph is required on every subscription. */ public Mono> executeBounded(BoundedR2dbcResources resources,R2dbcOperationBudget original, TransactionDefinition definition,CleanupPolicy cleanup,Function> work){ diff --git a/modules/r2dbc/src/main/resources/db/optional/immutable-audit/postgresql.sql b/modules/r2dbc/src/main/resources/db/optional/immutable-audit/postgresql.sql new file mode 100644 index 000000000..1ca5ec608 --- /dev/null +++ b/modules/r2dbc/src/main/resources/db/optional/immutable-audit/postgresql.sql @@ -0,0 +1,200 @@ +-- Explicit opt-in local audit installation; not a default Flyway migration. +-- Provision roles separately. Technical scope is not actor authentication or owner permission. +BEGIN; +DO $$ BEGIN + IF NOT pg_catalog.pg_has_role(current_user,'firefly_audit_guard','SET') + OR NOT pg_catalog.has_schema_privilege(current_user,'public','CREATE WITH GRANT OPTION') + OR pg_catalog.has_schema_privilege('firefly_audit_guard','public','CREATE') THEN + RAISE EXCEPTION 'Separately delegated audit installer required' USING ERRCODE='42501'; + END IF; + IF (SELECT count(*) FROM pg_catalog.pg_roles WHERE rolname IN('firefly_audit_guard','firefly_audit_writer','firefly_audit_reader') + AND NOT rolsuper AND NOT rolbypassrls AND NOT rolinherit AND NOT rolcreatedb AND NOT rolcreaterole AND NOT rolreplication + AND rolcanlogin=(rolname<>'firefly_audit_guard'))<>3 + OR pg_catalog.pg_has_role('firefly_audit_writer','firefly_audit_guard','MEMBER') + OR pg_catalog.pg_has_role('firefly_audit_reader','firefly_audit_guard','MEMBER') THEN + RAISE EXCEPTION 'Exact preprovisioned audit roles required' USING ERRCODE='42501'; + END IF; +END $$; +CREATE SCHEMA firefly_audit AUTHORIZATION firefly_audit_guard; +CREATE SCHEMA firefly_audit_private AUTHORIZATION firefly_audit_guard; +GRANT CREATE ON SCHEMA public TO firefly_audit_guard; +SET LOCAL ROLE firefly_audit_guard; +REVOKE ALL ON SCHEMA firefly_audit,firefly_audit_private FROM PUBLIC,firefly_audit_writer,firefly_audit_reader; +GRANT USAGE ON SCHEMA firefly_audit TO firefly_audit_writer,firefly_audit_reader; +CREATE TABLE firefly_audit.audit_head ( + resource_tenant_id UUID NOT NULL,service TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id UUID NOT NULL, + last_sequence BIGINT NOT NULL CHECK(last_sequence>=1),last_hash TEXT NOT NULL CHECK(last_hash ~ '^[0-9a-f]{64}$'), + PRIMARY KEY(resource_tenant_id,service,resource_type,resource_id) +); +CREATE TABLE firefly_audit.audit_event ( + resource_tenant_id UUID NOT NULL,service TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id UUID NOT NULL,event_id UUID NOT NULL, + sequence BIGINT NOT NULL CHECK(sequence>=1),previous_hash TEXT NOT NULL CHECK(previous_hash ~ '^[0-9a-f]{64}$'),record_hash TEXT NOT NULL CHECK(record_hash ~ '^[0-9a-f]{64}$'), + recorded_seconds BIGINT NOT NULL,recorded_nanos INTEGER NOT NULL CHECK(recorded_nanos BETWEEN 0 AND 999999999), + actor_issuer TEXT NOT NULL,actor_subject TEXT NOT NULL,actor_client_id TEXT NOT NULL,actor_platform_tenant_id TEXT NOT NULL, + actor_staff_realm TEXT,actor_staff_id UUID,action TEXT NOT NULL,outcome TEXT NOT NULL, + process_id UUID NOT NULL,correlation_id UUID NOT NULL,causation_id UUID,occurred_seconds BIGINT NOT NULL, + occurred_nanos INTEGER NOT NULL CHECK(occurred_nanos BETWEEN 0 AND 999999999),evidence_digest TEXT NOT NULL CHECK(evidence_digest ~ '^[0-9a-f]{64}$'),policy_revision TEXT NOT NULL, + parent_sequence BIGINT GENERATED ALWAYS AS(CASE WHEN sequence=1 THEN NULL ELSE sequence-1 END) STORED, + PRIMARY KEY(resource_tenant_id,event_id),UNIQUE(resource_tenant_id,service,resource_type,resource_id,sequence,record_hash), + UNIQUE(resource_tenant_id,service,resource_type,resource_id,sequence), + CHECK((actor_staff_realm IS NULL)=(actor_staff_id IS NULL)), + CHECK(sequence<>1 OR previous_hash=repeat('0',64)), + FOREIGN KEY(resource_tenant_id,service,resource_type,resource_id,parent_sequence,previous_hash) + REFERENCES firefly_audit.audit_event(resource_tenant_id,service,resource_type,resource_id,sequence,record_hash) DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE firefly_audit.audit_outbox ( + resource_tenant_id UUID NOT NULL,event_id UUID NOT NULL,service TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id UUID NOT NULL, + sequence BIGINT NOT NULL,record_hash TEXT NOT NULL,intent TEXT NOT NULL CHECK(intent='AUDIT_EXPORT'), + PRIMARY KEY(resource_tenant_id,event_id), + FOREIGN KEY(resource_tenant_id,event_id) REFERENCES firefly_audit.audit_event(resource_tenant_id,event_id) DEFERRABLE INITIALLY DEFERRED +); +ALTER TABLE firefly_audit.audit_head ADD CONSTRAINT audit_head_record_fk + FOREIGN KEY(resource_tenant_id,service,resource_type,resource_id,last_sequence,last_hash) + REFERENCES firefly_audit.audit_event(resource_tenant_id,service,resource_type,resource_id,sequence,record_hash) DEFERRABLE INITIALLY DEFERRED; +CREATE TABLE firefly_audit_private.context ( + nonce UUID PRIMARY KEY,xid XID8 NOT NULL,backend_pid INTEGER NOT NULL,db_session_user NAME NOT NULL, + resource_tenant_id UUID NOT NULL,service TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id UUID NOT NULL,event_id UUID NOT NULL, + actor_issuer TEXT NOT NULL,actor_subject TEXT NOT NULL,actor_client_id TEXT NOT NULL,actor_platform_tenant_id TEXT NOT NULL,actor_staff_realm TEXT,actor_staff_id UUID, + installed_at TIMESTAMPTZ NOT NULL,until_time TIMESTAMPTZ NOT NULL,new_event BOOLEAN NOT NULL, + CHECK(isfinite(installed_at) AND isfinite(until_time) AND until_time>installed_at AND until_time<=installed_at+INTERVAL '5 seconds') +); +CREATE TABLE firefly_audit_private.completion (nonce UUID PRIMARY KEY REFERENCES firefly_audit_private.context(nonce)); +REVOKE ALL ON ALL TABLES IN SCHEMA firefly_audit_private FROM PUBLIC,firefly_audit_writer,firefly_audit_reader; +CREATE FUNCTION firefly_audit_private.context_for(p_nonce UUID) RETURNS firefly_audit_private.context +LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE c firefly_audit_private.context%ROWTYPE;observed TIMESTAMPTZ; +BEGIN + SELECT * INTO c FROM firefly_audit_private.context WHERE nonce=p_nonce AND xid=pg_catalog.pg_current_xact_id_if_assigned() + AND backend_pid=pg_catalog.pg_backend_pid() AND db_session_user=session_user; + observed:=pg_catalog.clock_timestamp(); + IF NOT FOUND OR session_user<>'firefly_audit_writer' OR observed=c.until_time THEN + RAISE EXCEPTION 'Original active audit scope required' USING ERRCODE='42501'; + END IF; + RETURN c; +END $$; +CREATE FUNCTION public.firefly_audit_scope_matches(p_tenant UUID,p_service TEXT,p_type TEXT,p_resource UUID,p_event UUID,p_write BOOLEAN) RETURNS BOOLEAN +LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path=pg_catalog AS $$ +BEGIN + RETURN session_user='firefly_audit_writer' AND EXISTS(SELECT 1 FROM firefly_audit_private.context c + WHERE c.xid=pg_catalog.pg_current_xact_id_if_assigned() AND c.backend_pid=pg_catalog.pg_backend_pid() AND c.db_session_user=session_user + AND c.resource_tenant_id=p_tenant AND c.service=p_service AND c.resource_type=p_type AND c.resource_id=p_resource + AND(p_event IS NULL OR c.event_id=p_event) AND(NOT p_write OR c.new_event) + AND pg_catalog.clock_timestamp()>=c.installed_at AND pg_catalog.clock_timestamp()'firefly_audit_writer' OR r.rolsuper OR r.rolbypassrls OR r.rolinherit OR r.rolcreatedb OR r.rolcreaterole OR r.rolreplication + OR pg_catalog.pg_has_role(session_user,'firefly_audit_guard','MEMBER') OR pg_catalog.has_schema_privilege(session_user,'public','CREATE') + OR pg_catalog.has_schema_privilege(session_user,'firefly_audit_private','USAGE') THEN + RAISE EXCEPTION 'Restricted audit business writer required' USING ERRCODE='42501'; + END IF; + IF p_nonce IS NULL OR p_nonce='00000000-0000-0000-0000-000000000000'::uuid OR p_tenant IS NULL OR p_resource IS NULL OR p_event IS NULL + OR p_service IS NULL OR p_service !~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' OR p_type IS NULL OR p_type !~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' + OR p_issuer IS NULL OR length(p_issuer) NOT BETWEEN 1 AND 2048 OR p_subject IS NULL OR length(p_subject) NOT BETWEEN 1 AND 2048 + OR p_client IS NULL OR length(p_client) NOT BETWEEN 1 AND 2048 OR p_platform IS NULL OR length(p_platform) NOT BETWEEN 1 AND 2048 + OR(p_realm IS NULL)<>(p_staff IS NULL) OR(p_realm IS NOT NULL AND length(p_realm) NOT BETWEEN 1 AND 2048) + OR p_until IS NULL OR NOT isfinite(p_until) THEN + RAISE EXCEPTION 'Exact admitted audit values required' USING ERRCODE='42501'; + END IF; + IF EXISTS(SELECT 1 FROM firefly_audit_private.context c WHERE c.xid=pg_catalog.pg_current_xact_id_if_assigned() + AND c.backend_pid=pg_catalog.pg_backend_pid() AND c.db_session_user=session_user + AND NOT EXISTS(SELECT 1 FROM firefly_audit_private.completion done WHERE done.nonce=c.nonce)) THEN + RAISE EXCEPTION 'Previous audit append must complete first' USING ERRCODE='42501'; + END IF; + -- Stable event identity first, then stream, for every legitimate append/replay. + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('audit-event:'||p_tenant::text||':'||p_event::text,0)); + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('audit-stream:'||p_tenant::text||':'||p_service||':'||p_type||':'||p_resource::text,0)); + observed:=pg_catalog.date_trunc('milliseconds',pg_catalog.clock_timestamp());cap:=LEAST(p_until,observed+INTERVAL '5 seconds'); + IF cap<=observed THEN RAISE EXCEPTION 'Original audit deadline required' USING ERRCODE='42501';END IF; + SELECT NOT EXISTS(SELECT 1 FROM firefly_audit.audit_event a WHERE a.resource_tenant_id=p_tenant AND a.event_id=p_event) INTO fresh; + INSERT INTO firefly_audit_private.context VALUES(p_nonce,pg_catalog.pg_current_xact_id(),pg_catalog.pg_backend_pid(),session_user,p_tenant,p_service,p_type,p_resource,p_event,p_issuer,p_subject,p_client,p_platform,p_realm,p_staff,observed,cap,fresh); + RETURN QUERY SELECT p_nonce,p_event,pg_catalog.floor(extract(epoch FROM observed)*1000)::bigint,pg_catalog.floor(extract(epoch FROM cap)*1000)::bigint; +END $$; +CREATE FUNCTION firefly_audit_private.require_graph(c firefly_audit_private.context) RETURNS VOID +LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE a firefly_audit.audit_event%ROWTYPE; +BEGIN + SELECT * INTO a FROM firefly_audit.audit_event e WHERE e.resource_tenant_id=c.resource_tenant_id AND e.event_id=c.event_id + AND e.service=c.service AND e.resource_type=c.resource_type AND e.resource_id=c.resource_id + AND e.actor_issuer=c.actor_issuer AND e.actor_subject=c.actor_subject AND e.actor_client_id=c.actor_client_id + AND e.actor_platform_tenant_id=c.actor_platform_tenant_id AND e.actor_staff_realm IS NOT DISTINCT FROM c.actor_staff_realm + AND e.actor_staff_id IS NOT DISTINCT FROM c.actor_staff_id; + IF NOT FOUND OR(c.new_event AND(a.recorded_seconds<>pg_catalog.floor(extract(epoch FROM c.installed_at))::bigint + OR a.recorded_nanos<>((extract(epoch FROM c.installed_at)-pg_catalog.floor(extract(epoch FROM c.installed_at)))*1000000000)::integer)) + OR NOT EXISTS(SELECT 1 FROM firefly_audit.audit_outbox o WHERE o.resource_tenant_id=a.resource_tenant_id AND o.event_id=a.event_id + AND o.service=a.service AND o.resource_type=a.resource_type AND o.resource_id=a.resource_id AND o.sequence=a.sequence AND o.record_hash=a.record_hash AND o.intent='AUDIT_EXPORT') + OR NOT EXISTS(SELECT 1 FROM firefly_audit.audit_head h WHERE h.resource_tenant_id=a.resource_tenant_id AND h.service=a.service AND h.resource_type=a.resource_type AND h.resource_id=a.resource_id + AND h.last_sequence>=a.sequence AND(h.last_sequence<>a.sequence OR h.last_hash=a.record_hash)) THEN + RAISE EXCEPTION 'Complete immutable audit event and permanent outbox required' USING ERRCODE='23514'; + END IF; +END $$; +CREATE FUNCTION public.firefly_audit_complete(p_nonce UUID,p_sequence BIGINT,p_hash TEXT) RETURNS UUID +LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE c firefly_audit_private.context%ROWTYPE; +BEGIN + c:=firefly_audit_private.context_for(p_nonce);PERFORM firefly_audit_private.require_graph(c); + IF NOT EXISTS(SELECT 1 FROM firefly_audit.audit_event a WHERE a.resource_tenant_id=c.resource_tenant_id AND a.event_id=c.event_id AND a.sequence=p_sequence AND a.record_hash=p_hash) THEN + RAISE EXCEPTION 'Exact audit completion required' USING ERRCODE='23514'; + END IF; + INSERT INTO firefly_audit_private.completion VALUES(p_nonce);PERFORM firefly_audit_private.context_for(p_nonce);RETURN p_nonce; +END $$; +CREATE FUNCTION firefly_audit_private.deferred_complete() RETURNS TRIGGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE c firefly_audit_private.context%ROWTYPE; +BEGIN + c:=firefly_audit_private.context_for(NEW.nonce); + IF NOT EXISTS(SELECT 1 FROM firefly_audit_private.completion done WHERE done.nonce=c.nonce) THEN + RAISE EXCEPTION 'Completed audit append required' USING ERRCODE='23514'; + END IF; + PERFORM firefly_audit_private.require_graph(c);RETURN NULL; +END $$; +CREATE CONSTRAINT TRIGGER complete_audit_scope AFTER INSERT ON firefly_audit_private.context DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION firefly_audit_private.deferred_complete(); +CREATE FUNCTION firefly_audit_private.immutable_original() RETURNS TRIGGER +LANGUAGE plpgsql SET search_path=pg_catalog AS $$ BEGIN RAISE EXCEPTION 'Immutable audit original required' USING ERRCODE='42501';END $$; +CREATE TRIGGER immutable_audit_original BEFORE UPDATE OR DELETE ON firefly_audit.audit_event FOR EACH ROW EXECUTE FUNCTION firefly_audit_private.immutable_original(); +CREATE TRIGGER immutable_audit_original BEFORE UPDATE OR DELETE ON firefly_audit.audit_outbox FOR EACH ROW EXECUTE FUNCTION firefly_audit_private.immutable_original(); +CREATE FUNCTION firefly_audit_private.advance_head() RETURNS TRIGGER +LANGUAGE plpgsql SET search_path=pg_catalog AS $$ +BEGIN + IF ROW(NEW.resource_tenant_id,NEW.service,NEW.resource_type,NEW.resource_id) IS DISTINCT FROM ROW(OLD.resource_tenant_id,OLD.service,OLD.resource_type,OLD.resource_id) + OR OLD.last_sequence=9223372036854775807 OR NEW.last_sequence<>OLD.last_sequence+1 THEN + RAISE EXCEPTION 'Monotonic audit head required' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER advance_audit_head BEFORE UPDATE ON firefly_audit.audit_head FOR EACH ROW EXECUTE FUNCTION firefly_audit_private.advance_head(); +ALTER TABLE firefly_audit.audit_head ENABLE ROW LEVEL SECURITY; +ALTER TABLE firefly_audit.audit_head FORCE ROW LEVEL SECURITY; +ALTER TABLE firefly_audit.audit_event ENABLE ROW LEVEL SECURITY; +ALTER TABLE firefly_audit.audit_event FORCE ROW LEVEL SECURITY; +ALTER TABLE firefly_audit.audit_outbox ENABLE ROW LEVEL SECURITY; +ALTER TABLE firefly_audit.audit_outbox FORCE ROW LEVEL SECURITY; +DO $$ DECLARE relation TEXT;BEGIN + FOREACH relation IN ARRAY ARRAY['audit_head','audit_event','audit_outbox'] LOOP + EXECUTE pg_catalog.format('REVOKE ALL ON firefly_audit.%I FROM PUBLIC,firefly_audit_writer,firefly_audit_reader',relation); + EXECUTE pg_catalog.format('CREATE POLICY audit_guard_integrity ON firefly_audit.%I TO firefly_audit_guard USING(true) WITH CHECK(true)',relation); + EXECUTE pg_catalog.format('CREATE POLICY audit_scoped_read ON firefly_audit.%I FOR SELECT TO firefly_audit_writer USING(public.firefly_audit_scope_matches(resource_tenant_id,service,resource_type,resource_id,%s,false))',relation,CASE WHEN relation='audit_head' THEN 'NULL' ELSE 'event_id' END); + EXECUTE pg_catalog.format('CREATE POLICY audit_scoped_insert ON firefly_audit.%I FOR INSERT TO firefly_audit_writer WITH CHECK(public.firefly_audit_scope_matches(resource_tenant_id,service,resource_type,resource_id,%s,true))',relation,CASE WHEN relation='audit_head' THEN 'NULL' ELSE 'event_id' END); + END LOOP; +END $$; +CREATE POLICY audit_head_cas ON firefly_audit.audit_head FOR UPDATE TO firefly_audit_writer + USING(public.firefly_audit_scope_matches(resource_tenant_id,service,resource_type,resource_id,NULL,true)) + WITH CHECK(public.firefly_audit_scope_matches(resource_tenant_id,service,resource_type,resource_id,NULL,true)); +GRANT SELECT,INSERT ON firefly_audit.audit_event TO firefly_audit_writer; +GRANT SELECT,INSERT ON firefly_audit.audit_outbox TO firefly_audit_writer; +GRANT SELECT,INSERT,UPDATE(last_sequence,last_hash) ON firefly_audit.audit_head TO firefly_audit_writer; +GRANT SELECT ON firefly_audit.audit_head,firefly_audit.audit_event,firefly_audit.audit_outbox TO firefly_audit_reader; +-- Reader RLS has no default policy; an independently admitted export/read adapter is a later gate. +REVOKE ALL ON ALL FUNCTIONS IN SCHEMA firefly_audit_private FROM PUBLIC,firefly_audit_writer,firefly_audit_reader; +REVOKE ALL ON FUNCTION public.firefly_audit_enter(UUID,UUID,TEXT,TEXT,UUID,UUID,TEXT,TEXT,TEXT,TEXT,TEXT,UUID,TIMESTAMPTZ),public.firefly_audit_complete(UUID,BIGINT,TEXT),public.firefly_audit_scope_matches(UUID,TEXT,TEXT,UUID,UUID,BOOLEAN) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.firefly_audit_enter(UUID,UUID,TEXT,TEXT,UUID,UUID,TEXT,TEXT,TEXT,TEXT,TEXT,UUID,TIMESTAMPTZ),public.firefly_audit_complete(UUID,BIGINT,TEXT),public.firefly_audit_scope_matches(UUID,TEXT,TEXT,UUID,UUID,BOOLEAN) TO firefly_audit_writer; +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM firefly_audit_guard; +DO $$ BEGIN IF pg_catalog.has_schema_privilege('firefly_audit_guard','public','CREATE') THEN RAISE EXCEPTION 'Audit temporary installation grant cleanup required' USING ERRCODE='42501';END IF;END $$; +COMMIT; diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/AuditTestFixture.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/AuditTestFixture.java new file mode 100644 index 000000000..fc7d84f96 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/AuditTestFixture.java @@ -0,0 +1,72 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit; + +import io.r2dbc.spi.*; +import java.lang.reflect.Proxy; +import java.time.*; +import java.util.*; +import java.util.function.*; +import java.util.regex.*; +import org.fireflyframework.core.transaction.*; +import org.fireflyframework.core.transaction.impl.DefaultBoundedRootTransaction; +import org.reactivestreams.Publisher; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.dialect.PostgresDialect; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.*; + +/** Networkless mapping/count/transaction model; never PostgreSQL or authority qualification. */ +final class AuditTestFixture { + Throwable workFailure;final Clock clock=Clock.systemUTC();final LongSupplier nanos=System::nanoTime; + final Driver driver=new Driver();final OwnedLeaseConnectionFactory factory=new OwnedLeaseConnectionFactory(driver);final DatabaseClient client=DatabaseClient.create(factory);final R2dbcEntityTemplate template=new R2dbcEntityTemplate(client,PostgresDialect.INSTANCE);final R2dbcTransactionManager manager=new R2dbcTransactionManager(factory);final BoundedR2dbcResources resources=BoundedR2dbcResources.verify(factory,client,template,manager);final BoundedRootTransaction root=new DefaultBoundedRootTransaction(resources,new CleanupPolicy(Duration.ofSeconds(1),Duration.ofSeconds(1))); + Mono> execute(Function> work){var now=clock.instant();return root.execute(R2dbcOperationBudget.observed(now,now.plusSeconds(5),nanos.getAsLong(),Duration.ofSeconds(5),clock,nanos),new DefaultTransactionDefinition(),scope->work.apply(scope).doOnError(error->workFailure=error));} + static final class Driver implements ConnectionFactory { + Map>> tables=new HashMap<>(),snapshot;final List sql=new ArrayList<>();final List> bindings=new ArrayList<>();int creates,writes,selects,affectedObservations,commits,rollbacks,closes,failWrite,nativeEnters,nativeCompletions,countAtWrite;long writeCount=1;boolean emptyCount;boolean autoCommit=true; + Map>> copy(){var out=new HashMap>>();tables.forEach((t,rows)->out.put(t,new ArrayList<>(rows.stream().map(LinkedHashMap::new).toList())));return out;} + final Connection connection=(Connection)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{Connection.class},(p,m,a)->switch(m.getName()){ + case "isAutoCommit"->autoCommit;case "setAutoCommit"->{autoCommit=(boolean)a[0];yield Mono.empty();}case "getTransactionIsolationLevel"->IsolationLevel.READ_COMMITTED; + case "beginTransaction"->Mono.fromRunnable(()->snapshot=copy());case "commitTransaction"->Mono.fromRunnable(()->commits++);case "rollbackTransaction"->Mono.fromRunnable(()->{rollbacks++;tables=snapshot;});case "close"->Mono.fromRunnable(()->closes++); + case "createStatement"->statement((String)a[0]);case "hashCode"->System.identityHashCode(p);case "equals"->p==a[0];case "toString"->"AuditNetworklessDriver";default->Mono.empty();}); + Statement statement(String originalQuery){sql.add(originalQuery);String query=originalQuery.replace("\"","");var bound=new LinkedHashMap();return (Statement)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{Statement.class},(p,m,a)->{ + if(m.getName().equals("bind")||m.getName().equals("bindNull")){Object value=a[1];if(value instanceof Parameter parameter)value=parameter.getValue();bound.put((Integer)a[0],value instanceof Class?null:value);return p;} + if(!m.getName().equals("execute"))return p; + return Mono.defer(()->{ + bindings.add(new LinkedHashMap<>(bound)); + if(query.contains("firefly_audit_enter")){nativeEnters++;return Mono.just(result(List.of(Map.of("context_nonce",bound.get(0),"event_id",bound.get(5),"db_now_millis",System.currentTimeMillis(),"until_millis",((java.time.OffsetDateTime)bound.get(12)).toInstant().toEpochMilli())),0,false));} + if(query.contains("firefly_audit_complete")){nativeCompletions++;return Mono.just(result(List.of(Map.of("nonce",bound.get(0))),0,false));} + String table=table(query);boolean mutation=query.startsWith("INSERT")||query.startsWith("UPDATE"); + if(mutation){writes++;if(writes==failWrite)return Mono.error(new R2dbcDataIntegrityViolationException("ordinary duplicate"));long actualCount=countAtWrite==0||writes==countAtWrite?writeCount:1;if(actualCount==1){ + if(query.startsWith("INSERT")){String text=query.substring(query.indexOf('(')+1,query.indexOf(')'));String[] columns=text.replace("\"","").split(",");var row=new LinkedHashMap();for(int i=0;inew ArrayList<>()).add(row);} + else{var row=tables.get(table).getFirst();var matcher=Pattern.compile("([a-z_]+) = [$]([0-9]+)").matcher(query.substring(query.indexOf(" SET ")+5,query.indexOf(" WHERE ")));while(matcher.find())row.put(matcher.group(1),bound.get(Integer.parseInt(matcher.group(2))-1));} + }return Mono.just(result(List.of(),actualCount,true));} + selects++;var rows=new ArrayList<>(tables.getOrDefault(table,List.of()));var matches=Pattern.compile("(?:[a-z_]+[.])?([a-z_]+) = [$]([0-9]+)").matcher(query);while(matches.find()){String col=matches.group(1);Object v=bound.get(Integer.parseInt(matches.group(2))-1);rows.removeIf(row->!Objects.equals(row.get(col),v));}return Mono.just(result(rows,0,false)); + }); + });} + String table(String query){var m=Pattern.compile("(?:INTO|UPDATE|FROM) (?:[a-z_]+[.])?([a-z_]+)").matcher(query.replace("\"",""));if(!m.find())throw new IllegalArgumentException(query);return m.group(1);} + Result result(List> values,long count,boolean mutation){return new Result(){ + public Publisher getRowsUpdated(){affectedObservations++;return emptyCount&&(countAtWrite==0||writes==countAtWrite)?Mono.empty():Mono.just(count);} + public Publisher map(BiFunction f){return Flux.fromIterable(values).map(v->f.apply(row(v),metadata(v)));} + public Publisher map(Function f){return Flux.fromIterable(values).map(v->f.apply(row(v)));} + public Result filter(Predicate p){return this;}public Publisher flatMap(Function> f){return Flux.empty();} + };} + Row row(Map values){return (Row)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{Row.class},(p,m,a)->{if(m.getName().equals("getMetadata"))return metadata(values);Object key=a[0]instanceof Integer i?new ArrayList<>(values.keySet()).get(i):a[0];return values.get(key);});} + RowMetadata metadata(Map values){List columns=values.keySet().stream().map(n->(ColumnMetadata)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{ColumnMetadata.class},(p,m,a)->switch(m.getName()){case "getName"->n;case "getJavaType"->values.get(n)==null?String.class:values.get(n).getClass();case "getType"->new Type(){public Class getJavaType(){return values.get(n)==null?String.class:values.get(n).getClass();}public String getName(){return "OrdinaryTypedColumn";}};default->null;})).toList();return (RowMetadata)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{RowMetadata.class},(p,m,a)->switch(m.getName()){case "getColumnMetadatas"->columns;case "getColumnMetadata"->a[0]instanceof Integer i?columns.get(i):columns.stream().filter(c->c.getName().equals(a[0])).findFirst().orElseThrow();case "contains"->values.containsKey(a[0]);default->null;});} + public Publisher create(){creates++;return Mono.just(connection);}public ConnectionFactoryMetadata getMetadata(){return ()->"PostgreSQL";} + } +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/LocalAuditRepositoryTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/LocalAuditRepositoryTest.java new file mode 100644 index 000000000..d231f2c56 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/LocalAuditRepositoryTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit; + +import static org.junit.jupiter.api.Assertions.*; +import java.time.*; +import java.util.*; +import org.fireflyframework.core.audit.impl.SpringDataLocalAuditRepository; +import org.fireflyframework.kernel.audit.*; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +class LocalAuditRepositoryTest { + static UUID id(long n){return new UUID(0,n);} + static AuditEvent event(){return new AuditEvent(id(1),new AuditStream(id(2),"payments","transfer",id(3)),new AuditActor("https://issuer.test","staff-1","staff-app","sandbox-platform","staff",id(5)),"TRANSFER_POSTED",AuditEvent.Outcome.COMPLETED,id(6),id(7),id(8),Instant.ofEpochSecond(123456,987654321),"a".repeat(64),"policy-1");} + static AuditRecord record(){return AuditRecordHasher.record(AuditCheckpoint.genesis(event().stream()),event(),Instant.ofEpochMilli(123456789));} + @Test void fullKernelEventAndNanosecondFieldsRoundTripThroughMappedRows(){var row=AuditRows.Event.from(record());assertEquals(record(),row.record());assertEquals(987654321,row.occurredNanos());} + @Test void firstAppendUsesExactlyThreeAffectedRowCountsAndReadback(){ + var f=new AuditTestFixture();var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);var r=record(); + var done=f.execute(scope->store.append(scope,AuditCheckpoint.genesis(r.event().stream()),r)).block();assertEquals(r,done.value());assertEquals(3,f.driver.writes);assertEquals(3,f.driver.affectedObservations);assertEquals(1,f.driver.commits); + assertEquals(1,f.driver.tables.get("audit_event").size());assertEquals(1,f.driver.tables.get("audit_outbox").size()); + } + @Test void secondAppendUsesOriginalHeadVersionAndHashForCas(){ + var f=new AuditTestFixture();var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);var first=record();var next=new AuditEvent(id(9),event().stream(),event().actor(),event().action(),event().outcome(),event().processId(),event().correlationId(),null,event().occurredAt(),event().evidenceDigest(),event().policyRevision());var prior=new AuditCheckpoint(event().stream(),1,first.recordHash());var second=AuditRecordHasher.record(prior,next,first.recordedAt().plusMillis(1)); + assertDoesNotThrow(()->f.execute(scope->store.append(scope,AuditCheckpoint.genesis(event().stream()),first).then(store.append(scope,prior,second))).block(),()->String.valueOf(f.workFailure)+f.driver.sql);assertEquals(6,f.driver.writes);int update=java.util.stream.IntStream.range(0,f.driver.sql.size()).filter(i->f.driver.sql.get(i).startsWith("UPDATE")).findFirst().orElseThrow();assertEquals(List.of(2L,second.recordHash(),event().stream().resourceTenantId(),"payments","transfer",event().stream().resourceId(),1L,first.recordHash()),new ArrayList<>(f.driver.bindings.get(update).values()));assertEquals(2,f.execute(scope->store.head(scope,event().stream())).block().value().lastSequence()); + } + @Test void fullEventAndPermanentOutboxAreRequiredForReplayRead(){ + var f=new AuditTestFixture();var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);f.execute(scope->store.append(scope,AuditCheckpoint.genesis(event().stream()),record())).block();assertEquals(record(),f.execute(scope->store.find(scope,event().stream().resourceTenantId(),event().eventId())).block().value()); + f.driver.tables.get("audit_outbox").clear();assertThrows(RuntimeException.class,()->f.execute(scope->store.find(scope,event().stream().resourceTenantId(),event().eventId())).block()); + } + @Test void zeroAndTwoAffectedRowsAbortBeforeLaterWrites(){ + for(long count:new long[]{0,2})for(int position:new int[]{1,2,3}){var f=new AuditTestFixture();f.driver.writeCount=count;f.driver.countAtWrite=position;var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);assertThrows(RuntimeException.class,()->f.execute(scope->store.append(scope,AuditCheckpoint.genesis(event().stream()),record())).block());assertEquals(position,f.driver.writes);assertEquals(0,f.driver.commits);assertEquals(1,f.driver.rollbacks);} + } + @Test void missingAffectedRowObservationIsNeverSuccess(){for(int position:new int[]{1,2,3}){var f=new AuditTestFixture();f.driver.emptyCount=true;f.driver.countAtWrite=position;var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);assertThrows(RuntimeException.class,()->f.execute(scope->store.append(scope,AuditCheckpoint.genesis(event().stream()),record())).block());assertEquals(position,f.driver.writes);assertEquals(1,f.driver.rollbacks);}} + @Test void duplicateFailureHasNoLaterReadOrOutboxWrite(){var f=new AuditTestFixture();f.driver.failWrite=2;var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);assertThrows(RuntimeException.class,()->f.execute(scope->store.append(scope,AuditCheckpoint.genesis(event().stream()),record())).block());assertEquals(2,f.driver.writes);assertEquals(0,f.driver.selects);assertEquals(1,f.driver.rollbacks);assertTrue(f.driver.tables.isEmpty());} + @Test void zeroOrTwoHeadCasRowsAbortWithoutTheNextEventOrOutbox(){for(long count:new long[]{0,2}){var f=new AuditTestFixture();f.driver.countAtWrite=4;f.driver.writeCount=count;var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);var first=record();var prior=new AuditCheckpoint(event().stream(),1,first.recordHash());var e=event();var next=new AuditEvent(id(9),e.stream(),e.actor(),e.action(),e.outcome(),e.processId(),e.correlationId(),null,e.occurredAt(),e.evidenceDigest(),e.policyRevision());var second=AuditRecordHasher.record(prior,next,first.recordedAt().plusMillis(1));assertThrows(RuntimeException.class,()->f.execute(scope->store.append(scope,AuditCheckpoint.genesis(e.stream()),first).then(store.append(scope,prior,second))).block());assertEquals(4,f.driver.writes);assertEquals(1,f.driver.rollbacks);assertTrue(f.driver.tables.isEmpty());}} + @Test void lowLevelRepositoryCannotUseAnUnqualifiedOrdinaryAmbientTransaction(){var f=new AuditTestFixture();var driver=new AuditTestFixture.Driver();var client=org.springframework.r2dbc.core.DatabaseClient.create(driver);var template=new org.springframework.data.r2dbc.core.R2dbcEntityTemplate(client,org.springframework.data.r2dbc.dialect.PostgresDialect.INSTANCE);assertThrows(IllegalArgumentException.class,()->new SpringDataLocalAuditRepository(f.resources,client,template,new org.springframework.r2dbc.connection.R2dbcTransactionManager(driver)));assertEquals(0,driver.writes);assertEquals(0,driver.creates);} + @Test void noAmbientTransactionRefusesAllPublicStoreOperationsBeforeMappedStatements(){var f=new AuditTestFixture();var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);for(var operation:List.of(store.head(null,event().stream()),store.find(null,event().stream().resourceTenantId(),event().eventId()),store.append(null,AuditCheckpoint.genesis(event().stream()),record())))assertThrows(RuntimeException.class,operation::block);assertEquals(0,f.driver.creates);assertTrue(f.driver.sql.isEmpty());} + @Test void allPublicStoreOperationsRefuseAForeignRealScopeBeforeSql(){var owner=new AuditTestFixture();var foreign=new AuditTestFixture();var store=new SpringDataLocalAuditRepository(foreign.resources,foreign.client,foreign.template,foreign.manager);for(int operation:new int[]{0,1,2})assertThrows(RuntimeException.class,()->owner.execute(scope->operation==0?store.head(scope,event().stream()).map(Object.class::cast):operation==1?store.find(scope,event().stream().resourceTenantId(),event().eventId()).map(Object.class::cast):store.append(scope,AuditCheckpoint.genesis(event().stream()),record()).map(Object.class::cast)).block());assertTrue(foreign.driver.sql.isEmpty());assertEquals(0,foreign.driver.creates);} + @Test void allPublicStoreOperationsRefuseACompletedOriginalScopeBeforeSql(){var f=new AuditTestFixture();var retained=new java.util.concurrent.atomic.AtomicReference();f.execute(scope->{retained.set(scope);return Mono.just("closed");}).block();var store=new SpringDataLocalAuditRepository(f.resources,f.client,f.template,f.manager);for(var operation:List.of(store.head(retained.get(),event().stream()),store.find(retained.get(),event().stream().resourceTenantId(),event().eventId()),store.append(retained.get(),AuditCheckpoint.genesis(event().stream()),record())))assertThrows(RuntimeException.class,operation::block);assertEquals(1,f.driver.creates);assertTrue(f.driver.sql.isEmpty());} + +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/TransactionalAuditAppenderTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/TransactionalAuditAppenderTest.java new file mode 100644 index 000000000..74f0db6b2 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/audit/TransactionalAuditAppenderTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.audit; + +import static org.junit.jupiter.api.Assertions.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.fireflyframework.core.audit.impl.SpringDataTransactionalAuditAppender; +import org.fireflyframework.kernel.audit.*; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +class TransactionalAuditAppenderTest { + AuditEvent event(){return LocalAuditRepositoryTest.event();} + AuditAppendAuthority authority(AtomicInteger calls){return e->{calls.incrementAndGet();return Mono.just(new AuditAppendAuthority.Permit(e.stream(),e.actor(),Instant.now().plusSeconds(60)));};} + SpringDataTransactionalAuditAppender appender(AuditTestFixture f,AuditAppendAuthority authority){return new SpringDataTransactionalAuditAppender(f.resources,f.client,f.template,f.manager,authority,f.clock,f.nanos,UUID::randomUUID);} + @org.springframework.data.relational.core.mapping.Table("audit_test_business") record Business(@org.springframework.data.annotation.Id UUID id,long version) { } + @Test void mappedBusinessAndAuditOutboxCommitOnTheSameExistingLease(){var f=new AuditTestFixture();var service=appender(f,authority(new AtomicInteger()));var done=f.execute(scope->f.template.insert(new Business(UUID.randomUUID(),1)).then(service.append(scope,event()))).block();assertTrue(done.transactionFacts().commitAcknowledged());assertEquals(1,f.driver.creates);for(String table:List.of("audit_test_business","audit_event","audit_outbox"))assertEquals(1,f.driver.tables.get(table).size());} + @Test void caughtZeroTwoOrMissingMappedCountStillCannotCommitBusinessEffect(){ + for(int scenario:new int[]{0,1,2}){var f=new AuditTestFixture();var service=appender(f,authority(new AtomicInteger()));var done=f.execute(scope->f.template.insert(new Business(UUID.randomUUID(),1)).then(Mono.defer(()->{f.driver.writeCount=scenario==1?2:scenario==0?0:1;f.driver.emptyCount=scenario==2;return service.append(scope,event());})).thenReturn("business").onErrorReturn("caught")).block();assertFalse(done.transactionFacts().commitAcknowledged());assertTrue(done.transactionFacts().rollbackAcknowledged());assertEquals(1,f.driver.rollbacks);assertTrue(f.driver.tables.isEmpty());assertEquals(0,f.driver.nativeCompletions);} + } + @Test void caughtMandatoryAppendRefusalStillRollsBackTheOwningBusinessTransaction(){var f=new AuditTestFixture();var service=appender(f,e->Mono.empty());var done=f.execute(scope->f.template.insert(new Business(UUID.randomUUID(),1)).then(service.append(scope,event())).thenReturn("business-value").onErrorReturn("caught-refusal")).block();assertFalse(done.transactionFacts().commitAcknowledged());assertTrue(done.transactionFacts().rollbackAcknowledged());assertEquals(0,f.driver.commits);assertEquals(1,f.driver.rollbacks);assertTrue(f.driver.tables.isEmpty());} + @Test void mandatoryAuthorityHasNoSuccessfulFallbackOrAutomaticBean(){var f=new AuditTestFixture();assertThrows(NullPointerException.class,()->appender(f,null));assertEquals(0,f.driver.creates);} + @Test void actualMappedNativeAndAuditOperationsUseOnlyTheOriginalLeaseAndReturnProvisionalEvidence(){ + var f=new AuditTestFixture();var calls=new AtomicInteger();var service=appender(f,authority(calls));var done=assertDoesNotThrow(()->f.execute(scope->service.append(scope,event()).doOnNext(provisional->{assertEquals(0,f.driver.commits);assertEquals(event(),provisional.record().event());assertFalse(provisional.replay());})).block(),()->String.valueOf(f.workFailure)+f.driver.sql); + assertTrue(done.transactionFacts().commitAcknowledged());assertTrue(done.transactionFacts().boundaryCompleted());assertEquals(1,f.driver.creates);assertEquals(1,f.driver.closes);assertEquals(1,f.driver.nativeEnters);assertEquals(1,f.driver.nativeCompletions);assertEquals(2,calls.get()); + } + @Test void sequentialEventsUseTheSameOriginalLeaseAndAdvanceTheStreamHead(){var f=new AuditTestFixture();var service=appender(f,authority(new AtomicInteger()));var e=event();var next=new AuditEvent(UUID.randomUUID(),e.stream(),e.actor(),e.action(),e.outcome(),e.processId(),e.correlationId(),e.causationId(),e.occurredAt(),e.evidenceDigest(),e.policyRevision());var done=f.execute(scope->service.append(scope,e).then(service.append(scope,next))).block();assertEquals(2,done.value().record().sequence());assertTrue(done.transactionFacts().commitAcknowledged());assertEquals(1,f.driver.creates);assertEquals(2,f.driver.nativeEnters);assertEquals(2,f.driver.nativeCompletions);assertEquals(2,f.driver.tables.get("audit_event").size());assertEquals(2,f.driver.tables.get("audit_outbox").size());} + @Test void repeatedFullContentEventReturnsOriginalRecordWithoutNewMutation(){var f=new AuditTestFixture();var service=appender(f,authority(new AtomicInteger()));var first=f.execute(scope->service.append(scope,event())).block().value();int writes=f.driver.writes;var second=f.execute(scope->service.append(scope,event())).block().value();assertTrue(second.replay());assertEquals(first.record(),second.record());assertEquals(writes,f.driver.writes);} + @Test void eventIdentityWithDifferentFullContentRefusesWithoutMutation(){var f=new AuditTestFixture();var service=appender(f,authority(new AtomicInteger()));f.execute(scope->service.append(scope,event())).block();int writes=f.driver.writes;var e=event();var changed=new AuditEvent(e.eventId(),e.stream(),e.actor(),"TRANSFER_FAILED",e.outcome(),e.processId(),e.correlationId(),e.causationId(),e.occurredAt(),e.evidenceDigest(),e.policyRevision());assertThrows(RuntimeException.class,()->f.execute(scope->service.append(scope,changed)).block());assertEquals(writes,f.driver.writes);} + @Test void unavailableCurrentAuthorityNeverEntersNativeOrWrites(){var f=new AuditTestFixture();var service=appender(f,e->Mono.empty());assertThrows(RuntimeException.class,()->f.execute(scope->service.append(scope,event())).block());assertEquals(0,f.driver.nativeEnters);assertEquals(0,f.driver.writes);assertEquals(1,f.driver.rollbacks);} + @Test void changedCurrentActorWithholdsAppendAndRollsBackAllAuditRows(){var f=new AuditTestFixture();var calls=new AtomicInteger();var service=appender(f,e->{var actor=calls.incrementAndGet()==1?e.actor():new AuditActor("https://issuer.test","other-staff","staff-app",e.actor().platformTenantId(),null,null);return Mono.just(new AuditAppendAuthority.Permit(e.stream(),actor,Instant.now().plusSeconds(60)));});assertThrows(RuntimeException.class,()->f.execute(scope->service.append(scope,event())).block());assertEquals(1,f.driver.rollbacks);assertTrue(f.driver.tables.isEmpty());assertEquals(0,f.driver.nativeCompletions);} + @Test void ownerFailureAfterProvisionalAppendRollsBackEvidenceAndOutbox(){var f=new AuditTestFixture();var service=appender(f,authority(new AtomicInteger()));assertThrows(RuntimeException.class,()->f.execute(scope->service.append(scope,event()).then(Mono.error(new IllegalStateException("ordinary business rollback")))).block());assertTrue(f.driver.tables.isEmpty());assertEquals(0,f.driver.commits);assertEquals(1,f.driver.rollbacks);} + @Test void shorterLiveCurrentPermitKeepsTheOriginalCapAndEarlierExpiry(){ + for(var validity:List.of(Duration.ofSeconds(60),Duration.ofMillis(500))){ + var f=new AuditTestFixture();var calls=new AtomicInteger(); + AuditAppendAuthority current=e->Mono.just(new AuditAppendAuthority.Permit(e.stream(),e.actor(),f.clock.instant().plus(calls.incrementAndGet()==1?Duration.ofSeconds(180):validity))); + var service=appender(f,current);var original=new java.util.concurrent.atomic.AtomicReference(); + var done=assertDoesNotThrow(()->f.execute(scope->{original.set(scope.remaining());return service.append(scope,event()).doOnNext(provisional->{ + assertTrue(scope.remaining().compareTo(original.get())<=0);assertTrue(scope.remaining().compareTo(validity)<=0); + });}).block()); + assertTrue(done.transactionFacts().commitAcknowledged());assertTrue(done.transactionFacts().boundaryCompleted()); + assertEquals(2,calls.get());assertEquals(1,f.driver.commits);assertEquals(1,f.driver.nativeCompletions); + } + } + @Test void originalExpiredDeadlineRefusesBeforeAuditStatements(){var f=new AuditTestFixture();var service=appender(f,authority(new AtomicInteger()));assertThrows(RuntimeException.class,()->f.execute(scope->{scope.narrow(org.fireflyframework.core.transaction.R2dbcOperationBudget.observed(Instant.now().minusSeconds(2),Instant.now().minusSeconds(1),System.nanoTime(),Duration.ofSeconds(1),Clock.systemUTC(),System::nanoTime));return service.append(scope,event());}).block());assertEquals(0,f.driver.writes);assertEquals(0,f.driver.nativeEnters);} + @Test void aDifferentMonotonicSupplierCannotReplaceTheOriginalBudgetPair(){var f=new AuditTestFixture();var service=new SpringDataTransactionalAuditAppender(f.resources,f.client,f.template,f.manager,authority(new AtomicInteger()),f.clock,System::nanoTime,UUID::randomUUID);assertThrows(RuntimeException.class,()->f.execute(scope->service.append(scope,event())).block());assertEquals(0,f.driver.writes);assertEquals(1,f.driver.rollbacks);} + @Test void missingScopeOutsideOwnerRootRefusesBeforeCurrentAuthorityOrAcquisition(){var f=new AuditTestFixture();var calls=new AtomicInteger();var service=appender(f,authority(calls));assertThrows(RuntimeException.class,()->service.append(null,event()).block());assertEquals(0,calls.get());assertEquals(0,f.driver.creates);} +} diff --git a/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedAuditOwnershipTest.java b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedAuditOwnershipTest.java new file mode 100644 index 000000000..95d876abb --- /dev/null +++ b/modules/r2dbc/src/test/java/org/fireflyframework/core/transaction/BoundedAuditOwnershipTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.fireflyframework.core.transaction; + +import java.time.*; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.transaction.reactive.TransactionalOperator; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import static org.junit.jupiter.api.Assertions.*; + +class BoundedAuditOwnershipTest { + static BoundedR2dbcResources resources(BoundedTransactionBoundaryTest.Fixture f){return BoundedR2dbcResources.verify(f.factory,f.client,f.template,f.manager);} + @Test void originalNarrowedScopeAndExactGraphCanAppendInTheExistingTransaction(){ + var f=new AtomicReference(); + StepVerifier.withVirtualTime(()->{var x=new BoundedTransactionBoundaryTest.Fixture();f.set(x);return x.execute(scope->{scope.narrow(x.time.budget(1000));return x.factory.requireBoundScope(resources(x),scope).thenReturn("provisional");});}) + .assertNext(r->{assertEquals("provisional",r.value());assertTrue(r.transactionFacts().commitAcknowledged());}).verifyComplete(); + assertEquals(1,f.get().driver.creates);assertEquals(1,f.get().driver.commits);assertEquals(1,f.get().driver.closes); + } + @Test void absentInvocationRefusesWithoutAcquisition(){ + var f=new BoundedTransactionBoundaryTest.Fixture();var scope=new RootTransactionScope(){public Duration remaining(){return Duration.ofSeconds(1);}public void narrow(R2dbcOperationBudget cap){}}; + assertThrows(IllegalStateException.class,()->f.factory.requireBoundScope(resources(f),scope).block());assertEquals(0,f.driver.creates); + } + @Test void completedRealScopeCannotBeReusedAfterClosure(){ + var retained=new AtomicReference();var f=new AtomicReference(); + StepVerifier.withVirtualTime(()->{var x=new BoundedTransactionBoundaryTest.Fixture();f.set(x);return x.execute(scope->{retained.set(scope);return Mono.just("closed");});}).expectNextCount(1).verifyComplete(); + assertThrows(IllegalStateException.class,()->f.get().factory.requireBoundScope(resources(f.get()),retained.get()).block());assertEquals(1,f.get().driver.creates); + } + @Test void aDifferentGraphOnTheSameFactoryIsNotTheOriginalResourceGraph(){ + var attempts=new java.util.concurrent.atomic.AtomicInteger(); + StepVerifier.withVirtualTime(()->{var f=new BoundedTransactionBoundaryTest.Fixture();var client=DatabaseClient.create(f.factory);var template=new R2dbcEntityTemplate(client,f.template.getDataAccessStrategy());var other=BoundedR2dbcResources.verify(f.factory,client,template,f.manager); + return f.execute(scope->f.factory.requireBoundScope(other,scope).then(Mono.fromSupplier(()->{attempts.incrementAndGet();return "write";})));}).expectError(RootTransactionException.class).verify();assertEquals(0,attempts.get()); + } + @Test void foreignFactoryCannotUseARealActiveScope(){ + var foreign=new BoundedTransactionBoundaryTest.Fixture(); + StepVerifier.withVirtualTime(()->{var f=new BoundedTransactionBoundaryTest.Fixture();return f.execute(scope->foreign.factory.requireBoundScope(resources(foreign),scope).thenReturn("write"));}).expectError(RootTransactionException.class).verify();assertEquals(0,foreign.driver.creates); + } + @Test void ordinaryAmbientTransactionDoesNotInventABoundedInvocation(){ + var f=new BoundedTransactionBoundaryTest.Fixture();var raw=new BoundedTransactionBoundaryTest.Driver();var foreignScope=new RootTransactionScope(){public Duration remaining(){return Duration.ofSeconds(1);}public void narrow(R2dbcOperationBudget cap){}}; + assertThrows(IllegalStateException.class,()->TransactionalOperator.create(new R2dbcTransactionManager(raw)).transactional(f.factory.requireBoundScope(resources(f),foreignScope).thenReturn("write")).block());assertEquals(0,f.driver.creates);assertEquals(1,raw.rollbacks); + } + @Test void completedScopeDoesNotMatchANewInvocationOnTheSameFactory(){ + var prior=new AtomicReference(); + StepVerifier.withVirtualTime(()->{var f=new BoundedTransactionBoundaryTest.Fixture();return f.execute(scope->{prior.set(scope);return Mono.just("one");}).flatMap(done->f.execute(current->f.factory.requireBoundScope(resources(f),prior.get()).thenReturn("write")));}).expectError(RootTransactionException.class).verify(); + } +} From b5934a9872ecaf8c73cdebd21242b846997ee7d2 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 28 Oct 2026 17:12:59 -0700 Subject: [PATCH 43/44] Accept monthly patch counters beyond two digits --- docs/site/versioning.md | 2 +- scripts/github_packages.py | 2 +- scripts/release_tools.py | 2 +- scripts/set_version.py | 4 ++-- tests/release/test_github_packages.py | 11 +++++++++++ tests/release/test_release_tools.py | 19 +++++++++++++++++++ tests/test_set_version.py | 10 ++++++++++ 7 files changed, 45 insertions(+), 5 deletions(-) diff --git a/docs/site/versioning.md b/docs/site/versioning.md index 9b6bc1060..17fec0b8b 100644 --- a/docs/site/versioning.md +++ b/docs/site/versioning.md @@ -2,7 +2,7 @@ ![One version train](assets/banners/versioning.svg) -Jafly uses padded CalVer versions: `YY.MM.PATCH` for a final release and `YY.MM.PATCH-SNAPSHOT` during development. The final component counts releases within the month; it does not represent a date. The current development version is `26.10.22`. +Jafly uses `YY.MM.PATCH` for a final release and `YY.MM.PATCH-SNAPSHOT` during development. The year and month use two digits. The final component is a nonnegative release counter within the month, not a day, and has no two-digit limit: `26.10.6`, `26.10.32`, and `26.10.100` are valid. Existing two-digit zero-padding, such as `26.10.06`, remains supported. The current development version is `26.10.22`. ## What the version identifies diff --git a/scripts/github_packages.py b/scripts/github_packages.py index 9f208937a..64309fa3b 100644 --- a/scripts/github_packages.py +++ b/scripts/github_packages.py @@ -26,7 +26,7 @@ REPO = re.compile(r'[a-z0-9][a-z0-9._-]*') SHA = re.compile(r'[0-9a-f]{64}') CANONICAL_HOMES = Path(__file__).with_name('package-homes.json') -VERSION = re.compile(r'\d{2}\.(?:0[1-9]|1[0-2])\.\d{2}(?:-SNAPSHOT)?') +VERSION = re.compile(r'\d{2}\.(?:0[1-9]|1[0-2])\.(?:\d{2}|[1-9]\d*|0)(?:-SNAPSHOT)?') def release_tools(root): diff --git a/scripts/release_tools.py b/scripts/release_tools.py index 34ce3acec..823b48453 100644 --- a/scripts/release_tools.py +++ b/scripts/release_tools.py @@ -17,7 +17,7 @@ import zipfile NS = {'m': 'http://maven.apache.org/POM/4.0.0'} -FINAL_VERSION = re.compile(r'\d{2}\.(?:0[1-9]|1[0-2])\.\d{2}') +FINAL_VERSION = re.compile(r'\d{2}\.(?:0[1-9]|1[0-2])\.(?:\d{2}|[1-9]\d*|0)') def check_reactor(root, tag=None, expected=None, snapshot=False): diff --git a/scripts/set_version.py b/scripts/set_version.py index e254748f0..eaecf0ed6 100644 --- a/scripts/set_version.py +++ b/scripts/set_version.py @@ -6,13 +6,13 @@ import xml.etree.ElementTree as ET NS = {'m': 'http://maven.apache.org/POM/4.0.0'} -PATTERN = re.compile(r'^(\d{2})\.(0[1-9]|1[0-2])\.(\d{2})(?:-SNAPSHOT)?$') +PATTERN = re.compile(r'^(\d{2})\.(0[1-9]|1[0-2])\.(\d{2}|[1-9]\d*|0)(?:-SNAPSHOT)?$') def set_version(root: pathlib.Path, version: str) -> list[pathlib.Path]: match = PATTERN.fullmatch(version) if match is None: - raise ValueError('Version must be YY.MM.PP or YY.MM.PP-SNAPSHOT with a valid month') + raise ValueError('Version must be YY.MM.PATCH or YY.MM.PATCH-SNAPSHOT with a valid month and numeric release counter') changes = {} for path in sorted(root.rglob('pom.xml')): if {'target', '.git'} & set(path.relative_to(root).parts): diff --git a/tests/release/test_github_packages.py b/tests/release/test_github_packages.py index 216541931..1710a553f 100644 --- a/tests/release/test_github_packages.py +++ b/tests/release/test_github_packages.py @@ -55,6 +55,17 @@ def do_GET(self): super().do_GET() +class PatchCounterCoordinateTests(unittest.TestCase): + def test_publication_inventory_accepts_numeric_patch_counters(self): + for version in ('26.10.0', '26.10.6', '26.10.06', '26.10.32', '26.10.100', '26.10.100-SNAPSHOT'): + with self.subTest(version=version): + projects = copy.deepcopy(CURRENT_PROJECTS) + for project in projects: + project['version'] = version + self.assertEqual(85, len(m.publishable(projects))) + self.assertEqual(r.required_paths(projects), m.required_paths(projects)) + + class MappedPublishingTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() diff --git a/tests/release/test_release_tools.py b/tests/release/test_release_tools.py index 2ab07e52f..ac5df67cb 100644 --- a/tests/release/test_release_tools.py +++ b/tests/release/test_release_tools.py @@ -47,6 +47,25 @@ def test_accepts_unified_explicit_versions(self): projects = self.tools.check_reactor(self.root, 'v26.10.18', expected=4) self.assertEqual(len(projects), 4) + def test_reactor_accepts_numeric_patch_counters_of_different_widths(self): + for version in ('26.10.0', '26.10.6', '26.10.06', '26.10.32', '26.10.100'): + with self.subTest(version=version): + self.pom(self.root, 'fireflyframework-jafly', version, modules=['build/parent', 'build/bom', 'modules/kernel']) + self.pom(self.root / 'build/parent', 'fireflyframework-parent', version) + self.pom(self.root / 'build/bom', 'fireflyframework-bom', version) + self.pom(self.root / 'modules/kernel', 'fireflyframework-kernel', version, packaging='jar') + projects = self.tools.check_reactor(self.root, 'v' + version, expected=4) + self.assertEqual({version}, {project['version'] for project in projects}) + + def test_immutable_tag_guard_accepts_patch_counters_of_different_widths(self): + subprocess.run(['git', 'init', '-q', str(self.root)], check=True) + subprocess.run(['git', '-C', str(self.root), '-c', 'user.name=Test', '-c', 'user.email=test@example.org', 'commit', '--allow-empty', '-qm', 'Initial'], check=True) + for version in ('26.10.0', '26.10.6', '26.10.06', '26.10.32', '26.10.100'): + with self.subTest(version=version): + tag = 'v' + version + subprocess.run(['git', '-C', str(self.root), 'tag', tag], check=True) + self.tools.check_tag_commit(self.root, tag) + def test_accepts_version_inherited_from_the_local_parent(self): path = self.root / 'modules/kernel/pom.xml' source = path.read_text().replace('26.10.18', '', 1) diff --git a/tests/test_set_version.py b/tests/test_set_version.py index 25e91fd78..ca1488cf1 100644 --- a/tests/test_set_version.py +++ b/tests/test_set_version.py @@ -37,6 +37,16 @@ def test_monthly_patch_is_not_a_calendar_day(self): tool.set_version(root, invalid) self.assertIn('26.10.32', pom.read_text()) + def test_patch_counter_supports_single_digits_and_more_than_two_digits(self): + tool = self.tool() + for version in ('26.10.0', '26.10.6', '26.10.06', '26.10.100', '26.10.1000-SNAPSHOT'): + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + pom = root / 'pom.xml' + pom.write_text('org.fireflyframework26.10.22') + self.assertEqual([pom], tool.set_version(root, version)) + self.assertIn('' + version + '', pom.read_text()) + def test_update_projects_framework_parents_dependencies_and_property(self): tool = self.tool() with tempfile.TemporaryDirectory() as directory: From eaa499b13639ae13ad929a1a575e0ad6b6ff4e7e Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 28 Oct 2026 17:44:58 -0700 Subject: [PATCH 44/44] Bind Batch publication plans to explicit namespace intent --- docs/site/releases.md | 10 ++ scripts/github_packages.py | 88 +++++++++--- scripts/initial-package-intent.json | 12 ++ tests/release/test_initial_package_intent.py | 142 +++++++++++++++++++ 4 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 scripts/initial-package-intent.json create mode 100644 tests/release/test_initial_package_intent.py diff --git a/docs/site/releases.md b/docs/site/releases.md index 6179d0156..b411028d6 100644 --- a/docs/site/releases.md +++ b/docs/site/releases.md @@ -81,3 +81,13 @@ actionlint .github/workflows/*.yml ``` Unit tests cover valid and invalid version inheritance, tag/commit mismatches, mixed versions, parent/BOM cycles, duplicate GAVs, missing projects, path traversal, required attachments, signed bundle completeness, and remote HTTP/digest failures using an isolated local HTTP server. These tests do not publish artifacts or inspect credentials. + +## Source-owned Batch namespace intent + +The current reactor has 87 projects and 85 publishable artifacts, requiring all 316 POM/JAR/source/Javadoc files. The historical canonical map still records 84 publication homes; its captured association file remains unchanged, including additional historical package rows. `scripts/initial-package-intent.json` is a separate source-owned intention for `org.fireflyframework:fireflyframework-batch` in the public `fireflyframework/fireflyframework-jafly` repository, ID 1423952543. It does not describe an existing package, allocate a namespace, grant write permission, select a version, or reserve a patch counter. + +An explicit `github_packages.py plan --initial-intent scripts/initial-package-intent.json` composes all 85 homes and requires a complete 316-file inventory. The plan retains the exact intent digest and historical association digest separately. It stages local files but produces no publisher POM, settings or deploy goals. Omitting the intent still refuses the unmapped Batch coordinate. + +Execution revalidates the committed intent and requires an actual HTTP 200 response for the existing Batch package with the exact Maven name, public visibility, owner and repository name/ID before creating publisher/settings files or invoking Maven. HTTP 404 is `UNKNOWN` and refuses; an inaccessible namespace is never treated as absence or creation authority. Conflicting metadata, authorization failures and HTTP 409 also refuse. The 84 historical bootstrap policies remain unchanged. + +This source intent does not make first publication runnable. An independently authorized provider-side allocation and exact readable existing-package association are still prerequisites. No package is created or uploaded by preparing the intent; no GitHub association or first-publication qualification follows from mocked source tests. All85 artifacts must be accounted for; Batch is never silently omitted. The human-selected `YY.MM.PATCH` counter policy is unchanged. diff --git a/scripts/github_packages.py b/scripts/github_packages.py index 64309fa3b..7c71df24f 100644 --- a/scripts/github_packages.py +++ b/scripts/github_packages.py @@ -26,6 +26,7 @@ REPO = re.compile(r'[a-z0-9][a-z0-9._-]*') SHA = re.compile(r'[0-9a-f]{64}') CANONICAL_HOMES = Path(__file__).with_name('package-homes.json') +CANONICAL_INITIAL_INTENT = Path(__file__).with_name('initial-package-intent.json') VERSION = re.compile(r'\d{2}\.(?:0[1-9]|1[0-2])\.(?:\d{2}|[1-9]\d*|0)(?:-SNAPSHOT)?') @@ -60,7 +61,27 @@ def required_paths(projects): return paths -def build_homes(projects, associations): +def validate_initial_intent(projects, intent): + expected = {'schema': 1, 'kind': 'SOURCE_OWNED_INITIAL_NAMESPACE_INTENT', + 'owner': OWNER, 'groupId': 'org.fireflyframework', + 'artifactId': 'fireflyframework-batch', 'repository': JAFLY_REPOSITORY, + 'repositoryId': JAFLY_REPOSITORY_ID, 'visibility': 'public', + 'requiredProviderQualification': 'EXISTING_EXACT_PACKAGE_GET_200', + 'on404': 'UNKNOWN_REFUSE'} + if (initial_intent_digest(intent) != initial_intent_digest(expected) + or initial_intent_digest(intent) != initial_intent_digest(json.loads(CANONICAL_INITIAL_INTENT.read_text())) + or ('org.fireflyframework', 'fireflyframework-batch') not in + {(p['groupId'], p['artifactId']) for p in publishable(projects)}): + raise ValueError('exact committed source-owned initial namespace intent required') + return {key: intent[key] for key in ('groupId', 'artifactId', 'repository', 'visibility')} + + +def initial_intent_digest(intent): + return hashlib.sha256(json.dumps(intent, sort_keys=True).encode()).hexdigest() + + +def build_homes(projects, associations, initial_intent=None): + initial = validate_initial_intent(projects, initial_intent) if initial_intent is not None else None by_name = {} for row in associations: if row['name'] in by_name: @@ -69,9 +90,12 @@ def build_homes(projects, associations): entries = [] for project in publishable(projects): name = project['groupId'] + '.' + project['artifactId'] - if name not in by_name: + is_initial = initial is not None and project['groupId'] == initial['groupId'] and project['artifactId'] == initial['artifactId'] + if is_initial and name in by_name: + raise ValueError('initial intent must not replace a captured association') + if name not in by_name and not is_initial: raise ValueError('missing existing package home: ' + name) - row = by_name[name] + row = initial if is_initial else by_name[name] if not REPO.fullmatch(row['repository']) or row['visibility'] not in ('public', 'private'): raise ValueError('invalid captured package association') entries.append({key: project[key] for key in ('groupId', 'artifactId')} | @@ -79,6 +103,8 @@ def build_homes(projects, associations): result = {'schema': 1, 'owner': OWNER, 'publicationMode': 'jafly-bootstrap', 'associationSha256': hashlib.sha256(json.dumps(associations, sort_keys=True).encode()).hexdigest(), 'artifacts': sorted(entries, key=lambda row: row['artifactId'])} + if initial is not None: + result['initialNamespaceIntentSha256'] = initial_intent_digest(initial_intent) validate_homes(projects, result) return result @@ -86,6 +112,8 @@ def build_homes(projects, associations): def validate_homes(projects, homes): if homes.get('schema') != 1 or homes.get('owner') != OWNER or not SHA.fullmatch(homes.get('associationSha256', '')): raise ValueError('untrusted package-home schema or owner') + if 'initialNamespaceIntentSha256' in homes and (not isinstance(homes['initialNamespaceIntentSha256'], str) or not SHA.fullmatch(homes['initialNamespaceIntentSha256'])): + raise ValueError('invalid initial namespace intent digest') mode = homes.get('publicationMode') if mode not in (None, 'jafly-bootstrap'): raise ValueError('unsupported package publication mode') @@ -106,16 +134,23 @@ def validate_homes(projects, homes): return entries -def validate_associations(projects, homes, associations): +def validate_associations(projects, homes, associations, initial_intent=None): validate_homes(projects, homes) - if homes != build_homes(projects, associations): + if homes != build_homes(projects, associations, initial_intent=initial_intent): raise ValueError('package-home map differs from captured associations') -def validate_canonical_homes(projects, homes, canonical=None): +def validate_canonical_homes(projects, homes, canonical=None, initial_intent=None): canonical = CANONICAL_HOMES if canonical is None else Path(canonical) committed = json.loads(canonical.read_text()) try: + if initial_intent is not None: + initial = validate_initial_intent(projects, initial_intent) + historical = [p for p in projects if (p['groupId'], p['artifactId']) != + (initial['groupId'], initial['artifactId'])] + validate_homes(historical, committed) + committed = committed | {'artifacts': sorted([*committed['artifacts'], initial], key=lambda row: row['artifactId']), + 'initialNamespaceIntentSha256': initial_intent_digest(initial_intent)} validate_homes(projects, committed) except ValueError: raise ValueError('invalid canonical package-home map') from None @@ -337,8 +372,16 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return None -def preflight_associations(projects, homes, token): +def preflight_associations(projects, homes, token, initial_intent=None): entries = validate_homes(projects, homes) + initial_name = None + if initial_intent is not None: + initial = validate_initial_intent(projects, initial_intent) + if homes.get('initialNamespaceIntentSha256') != initial_intent_digest(initial_intent): + raise ValueError('exact initial namespace intent binding required') + initial_name = initial['groupId'] + '.' + initial['artifactId'] + elif 'initialNamespaceIntentSha256' in homes: + raise ValueError('bound source-owned initial namespace intent required') if homes.get('publicationMode') != 'jafly-bootstrap': raise ValueError('explicit Jafly bootstrap publication policy required') opener = urllib.request.build_opener(NoApiRedirect()) @@ -353,11 +396,15 @@ def preflight_associations(projects, homes, token): }) try: with opener.open(request, timeout=60) as response: + if name == initial_name and getattr(response, 'status', None) != 200: + raise ValueError('initial namespace requires actual existing provider GET200: ' + name) data = response.read(1024 * 1024 + 1) except urllib.error.HTTPError as error: status = error.code error.close() if status == 404: + if name == initial_name: + raise ValueError('initial namespace provider association UNKNOWN: ' + name) from None proof.append({'name': name, 'status': 'absent'}) continue raise ValueError(f'HTTP {status} checking package association: {name}') from None @@ -386,8 +433,10 @@ def run_batch(maven, plan, local=False): homes = json.loads((plan / 'package-homes.json').read_text()) inventory = json.loads((plan / 'github-manifest.json').read_text()) associations = json.loads((plan / 'source-associations.json').read_text()) - validate_associations(projects, homes, associations) - validate_canonical_homes(projects, homes) + intent_path = plan / 'source-initial-intent.json' + initial_intent = json.loads(intent_path.read_text()) if intent_path.exists() else None + validate_associations(projects, homes, associations, initial_intent=initial_intent) + validate_canonical_homes(projects, homes, initial_intent=initial_intent) validate_inventory(projects, inventory) for row in inventory: if digest_file(plan / 'artifacts' / row['path']) != row['sha256']: @@ -400,7 +449,7 @@ def run_batch(maven, plan, local=False): token = os.environ.get('JAFLY_PACKAGES_TOKEN') username = os.environ.get('JAFLY_PACKAGES_USER') or os.environ.get('GITHUB_ACTOR') if not token or not username: raise ValueError('JAFLY_PACKAGES_TOKEN and JAFLY_PACKAGES_USER/GITHUB_ACTOR are required') - association_proof = preflight_associations(projects, homes, token) + association_proof = preflight_associations(projects, homes, token, initial_intent=initial_intent) settings = 'github-packages${env.JAFLY_PACKAGES_USER}${env.JAFLY_PACKAGES_TOKEN}\n' rendered, goals = render_publisher(projects, homes, plan / 'artifacts') pom = plan / ('local-publisher.pom.xml' if local else 'publisher.pom.xml') @@ -430,6 +479,7 @@ def main(): plan = commands.add_parser('plan') plan.add_argument('--root', required=True, type=Path) plan.add_argument('--associations', type=Path, help='Captured package metadata; defaults to scripts/package-associations.jsonl') + plan.add_argument('--initial-intent', type=Path, help='Explicit committed initial namespace intent; never provider existence or write authority') plan.add_argument('--repository', required=True, type=Path) plan.add_argument('--output', required=True, type=Path) execute = commands.add_parser('execute') @@ -439,6 +489,7 @@ def main(): verify.add_argument('--projects', required=True, type=Path) verify.add_argument('--homes', required=True, type=Path) verify.add_argument('--manifest', required=True, type=Path) + verify.add_argument('--initial-intent', type=Path) verify.add_argument('--output', required=True, type=Path) verify.add_argument('--token-env', default='JAFLY_PACKAGES_TOKEN') verify.add_argument('--attempts', type=int, default=1) @@ -449,16 +500,20 @@ def main(): projects = tools.check_reactor(args.root, expected=87) association_path = args.associations or args.root / 'scripts/package-associations.jsonl' associations = [json.loads(line) for line in association_path.read_text().splitlines() if line.strip()] - homes = build_homes(projects, associations) - validate_canonical_homes(projects, homes, args.root / 'scripts/package-homes.json') + initial_intent = json.loads(args.initial_intent.read_text()) if args.initial_intent is not None else None + homes = build_homes(projects, associations, initial_intent=initial_intent) + validate_canonical_homes(projects, homes, args.root / 'scripts/package-homes.json', initial_intent=initial_intent) inventory = tools.inventory_repository(projects, args.repository) if args.output.exists(): raise ValueError('refusing to overwrite an existing plan') prepare_stage(projects, homes, inventory, args.repository, args.output / 'artifacts') for filename, value in [('reactor-inventory.json', projects),('package-homes.json',homes),('github-manifest.json',inventory),('source-associations.json',associations)]: (args.output / filename).write_text(json.dumps(value, indent=2) + '\n') - rendered, goals = render_publisher(projects, homes, args.output / 'artifacts') - (args.output / 'publisher.pom.xml').write_text(rendered) - (args.output / 'goals.json').write_text(json.dumps(goals, indent=2) + '\n') + if initial_intent is not None: + (args.output / 'source-initial-intent.json').write_text(json.dumps(initial_intent, indent=2) + '\n') + if initial_intent is None: + rendered, goals = render_publisher(projects, homes, args.output / 'artifacts') + (args.output / 'publisher.pom.xml').write_text(rendered) + (args.output / 'goals.json').write_text(json.dumps(goals, indent=2) + '\n') print(f'Prepared {len(homes["artifacts"])} package homes and {len(inventory)} staged files; no upload performed') elif args.command == 'execute': run_batch(args.maven, args.plan) @@ -470,7 +525,8 @@ def main(): if not token or not username: raise ValueError('read credentials are required') projects = json.loads(args.projects.read_text()) homes = json.loads(args.homes.read_text()) - validate_canonical_homes(projects, homes) + initial_intent = json.loads(args.initial_intent.read_text()) if args.initial_intent is not None else None + validate_canonical_homes(projects, homes, initial_intent=initial_intent) proof = verify_with_retry(projects, homes, json.loads(args.manifest.read_text()), username=username, token=token, attempts=args.attempts, delay=args.delay) args.output.write_text(json.dumps(proof, indent=2) + '\n') diff --git a/scripts/initial-package-intent.json b/scripts/initial-package-intent.json new file mode 100644 index 000000000..074697704 --- /dev/null +++ b/scripts/initial-package-intent.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "kind": "SOURCE_OWNED_INITIAL_NAMESPACE_INTENT", + "owner": "fireflyframework", + "groupId": "org.fireflyframework", + "artifactId": "fireflyframework-batch", + "repository": "fireflyframework-jafly", + "repositoryId": 1423952543, + "visibility": "public", + "requiredProviderQualification": "EXISTING_EXACT_PACKAGE_GET_200", + "on404": "UNKNOWN_REFUSE" +} diff --git a/tests/release/test_initial_package_intent.py b/tests/release/test_initial_package_intent.py new file mode 100644 index 000000000..1ad289782 --- /dev/null +++ b/tests/release/test_initial_package_intent.py @@ -0,0 +1,142 @@ +"""Source intent is distinct from provider existence and publication authority.""" +import copy, io, json, os, subprocess, tempfile, unittest, urllib.error +from pathlib import Path +from unittest.mock import patch +from test_github_packages import m, r, CURRENT_PROJECTS, PROJECTS, ASSOCIATIONS, SOURCE, fixture_inventory +INTENT = json.loads((SOURCE / 'scripts/initial-package-intent.json').read_text()) +BATCH = 'org.fireflyframework.fireflyframework-batch' + +class InitialNamespaceTests(unittest.TestCase): + + def homes(self): + try: + return m.build_homes(CURRENT_PROJECTS, ASSOCIATIONS, initial_intent=INTENT) + except (TypeError, ValueError) as error: + self.fail('Explicit source intent must compose85 homes without fabricating history: ' + str(error)) + + def test_exact_source_intent_composes85_complete_homes_and316_files(self): + before = copy.deepcopy(ASSOCIATIONS) + homes = self.homes() + self.assertEqual(96, len(ASSOCIATIONS)) + self.assertEqual(before, ASSOCIATIONS) + self.assertEqual(85, len(homes['artifacts'])) + self.assertEqual(316, len(m.required_paths(CURRENT_PROJECTS))) + m.validate_inventory(CURRENT_PROJECTS, fixture_inventory(CURRENT_PROJECTS)) + m.validate_associations(CURRENT_PROJECTS, homes, ASSOCIATIONS, initial_intent=INTENT) + m.validate_canonical_homes(CURRENT_PROJECTS, homes, initial_intent=INTENT) + + def test_missing_intent_still_refuses_unmapped_batch(self): + with self.assertRaisesRegex(ValueError, 'missing existing package home'): + m.build_homes(CURRENT_PROJECTS, ASSOCIATIONS) + + def test_intent_cannot_override_history_or_name_another_coordinate_target_or_policy(self): + for key, value in [('schema', True), ('schema', 1.0), ('artifactId', 'another'), ('repositoryId', 1423952544), ('repository', 'another'), ('visibility', 'private'), ('on404', 'ALLOW'), ('requiredProviderQualification', 'FIRST_UPLOAD')]: + bad = copy.deepcopy(INTENT) + bad[key] = value + with self.subTest(key=key), self.assertRaises(ValueError): + m.build_homes(CURRENT_PROJECTS, ASSOCIATIONS, initial_intent=bad) + with self.assertRaises(ValueError): + m.build_homes(PROJECTS, ASSOCIATIONS, initial_intent=INTENT) + + def test_initial_intent_binding_must_survive_home_and_canonical_checks(self): + homes = self.homes() + homes['initialNamespaceIntentSha256'] = '0' * 64 + with self.assertRaises(ValueError): + m.validate_associations(CURRENT_PROJECTS, homes, ASSOCIATIONS, initial_intent=INTENT) + with self.assertRaises(ValueError): + m.validate_canonical_homes(CURRENT_PROJECTS, homes, initial_intent=INTENT) + + def execute(self, batch_reply, maven_code=0): + homes = self.homes() + calls = [] + + class Api: + + def open(inner, request, timeout): + name = request.full_url.rsplit('/', 1)[-1] + calls.append(name) + if name == BATCH and isinstance(batch_reply, int): + raise urllib.error.HTTPError(request.full_url, batch_reply, 'ordinary status', {}, io.BytesIO()) + row = {'name': name, 'package_type': 'maven', 'visibility': 'public', 'owner': {'login': 'fireflyframework'}, 'repository': {'full_name': 'fireflyframework/fireflyframework-jafly', 'id': 1423952543}} + status = 200 + if name == BATCH and isinstance(batch_reply, dict): + row.update(batch_reply) + if name == BATCH and isinstance(batch_reply, tuple): + status = batch_reply[0] + row.update(batch_reply[1]) + response = io.BytesIO(json.dumps(row).encode()) + response.status = status + return response + with tempfile.TemporaryDirectory() as directory: + plan = Path(directory) + inventory = fixture_inventory(CURRENT_PROJECTS) + for name, data in [('reactor-inventory.json', CURRENT_PROJECTS), ('package-homes.json', homes), ('github-manifest.json', inventory), ('source-associations.json', ASSOCIATIONS), ('source-initial-intent.json', INTENT)]: + (plan / name).write_text(json.dumps(data)) + for item in inventory: + path = plan / 'artifacts' / item['path'] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(item['path'].encode()) + with patch.dict(os.environ, {'JAFLY_PACKAGES_TOKEN': 'SYNTHETIC_INITIAL_TEST', 'GITHUB_ACTOR': 'ordinary-test'}, clear=True), patch.object(m.urllib.request, 'build_opener', return_value=Api()), patch.object(m.subprocess, 'run', return_value=subprocess.CompletedProcess([], maven_code, 'HTTP409 ordinary conflict' if maven_code else '', '')) as run: + try: + m.run_batch('/not/executed/maven', plan) + except ValueError: + if run.called: + self.assertNotEqual(0, maven_code) + else: + self.assertFalse((plan / 'publisher.pom.xml').exists()) + self.assertFalse((plan / 'settings.xml').exists()) + raise + self.assertEqual(1, run.call_count) + self.assertEqual(85, len(calls)) + self.assertEqual(85, len(json.loads((plan / 'association-preflight.json').read_text()))) + + def test_unknown404_refuses_before_any_publisher_settings_or_maven(self): + with self.assertRaisesRegex(ValueError, 'UNKNOWN'): + self.execute(404) + + def test_only_actual_exact_existing_provider200_permits_full_mocked_plan(self): + self.execute({}) + + def test_provider_conflict_or_authorization_failure_never_qualifies(self): + for reply in [401, 403, 409, 500, (201, {}), (204, {}), {'repository': {'full_name': 'fireflyframework/fireflyframework-jafly', 'id': 1}}, {'visibility': 'private'}]: + with self.subTest(reply=reply), self.assertRaises(ValueError): + self.execute(reply) + + def test_nonzero_maven_conflict_never_becomes_initial_publication_success(self): + with self.assertRaisesRegex(ValueError, 'Maven deployment failed'): + self.execute({}, maven_code=1) + + def test_partial84_inventory_never_qualifies85_coordinate_plan(self): + with self.assertRaises(ValueError): + m.validate_inventory(CURRENT_PROJECTS, fixture_inventory(PROJECTS)) + + def test_explicit_initial_cli_stages316_files_without_publisher_or_settings(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + repository = root / 'repository' + output = root / 'plan' + projects = m.publishable(CURRENT_PROJECTS) + for project in projects: + prefix = project['groupId'].replace('.', '/') + '/' + project['artifactId'] + '/' + project['version'] + '/' + for name in m.required_paths([project]): + path = repository / name + path.parent.mkdir(parents=True, exist_ok=True) + content = name.encode() + if name.endswith('.pom'): + content = ('' + project['groupId'] + '' + project['artifactId'] + '' + project['version'] + '' + project['packaging'] + '').encode() + path.write_bytes(content) + argv = ['github_packages.py', 'plan', '--root', str(SOURCE), '--repository', str(repository), '--output', str(output), '--initial-intent', str(SOURCE / 'scripts/initial-package-intent.json')] + with patch.object(m.sys, 'argv', argv): + m.main() + self.assertEqual(316, len(json.loads((output / 'github-manifest.json').read_text()))) + self.assertEqual(85, len(json.loads((output / 'package-homes.json').read_text())['artifacts'])) + self.assertEqual(INTENT, json.loads((output / 'source-initial-intent.json').read_text())) + self.assertFalse((output / 'publisher.pom.xml').exists()) + self.assertFalse((output / 'settings.xml').exists()) + self.assertFalse((output / 'goals.json').exists()) + + def test_legacy84_canonical_homes_and_associations_remain_byte_exact(self): + self.assertEqual(84, len(m.build_homes(PROJECTS, ASSOCIATIONS)['artifacts'])) + self.assertEqual(ASSOCIATIONS, [json.loads(line) for line in (SOURCE / 'scripts/package-associations.jsonl').read_text().splitlines()]) +if __name__ == '__main__': + unittest.main()