From 12b55c27710c566d6bbcf489ff2fa2bb615ce4b8 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:09:34 +0100 Subject: [PATCH] port remaining proprietary sources to quarkus --- QUARKUS_MIGRATION_TODO.md | 236 +++++----- .../common/model/io/InputStreamResource.java | 5 + .../software/common/model/io/Resource.java | 10 +- .../src/main/resources/application.properties | 5 + app/proprietary/build.gradle | 4 - .../access/config/AccessConfig.java | 17 +- .../controller/ResourceGrantController.java | 126 ++++-- .../repository/ResourceGrantRepository.java | 70 ++- .../security/ResourceAccessSecurity.java | 4 +- .../access/service/OwnershipService.java | 22 +- .../access/service/ResourceAccessService.java | 5 +- .../accountlink/AccountLinkClient.java | 5 +- .../accountlink/AccountLinkController.java | 118 +++-- .../accountlink/AccountLinkProperties.java | 40 +- .../accountlink/AccountLinkService.java | 5 +- .../AccountLinkSyncStateRepository.java | 23 +- .../accountlink/AccountLinkWebMvcConfig.java | 61 +-- .../DeviceCredentialRepository.java | 22 +- .../accountlink/DeviceCredentialStore.java | 12 +- .../accountlink/EntitlementCache.java | 5 +- .../accountlink/InstanceEntitlementGate.java | 13 +- .../InstanceEntitlementInterceptor.java | 187 +++++--- .../accountlink/LocalUsageService.java | 5 +- .../MeteredInputSignatureRepository.java | 37 +- .../accountlink/UsageCounterRepository.java | 72 +-- .../accountlink/UsageMeterService.java | 7 +- .../accountlink/UsageSyncService.java | 52 ++- .../api/ClassifyLabelController.java | 29 +- .../controller/api/FleetUsageController.java | 21 +- .../api/PortalApiKeysController.java | 49 +- .../api/PortalDocumentsController.java | 28 +- .../api/PortalInfraAuditController.java | 28 +- .../api/ApiConnectionResolver.java | 5 +- .../integration/api/ApiTokenCache.java | 6 +- .../api/ExternalApiCallController.java | 130 +++--- .../integration/api/ResultFiles.java | 54 ++- .../IntegrationConfigController.java | 132 +++--- .../crypto/CredentialEncryption.java | 28 +- .../purview/PurviewLabelController.java | 72 +-- .../IntegrationConfigRepository.java | 43 +- .../service/IntegrationConfigService.java | 39 +- .../proprietary/model/TeamEntityListener.java | 17 +- .../ClassificationMeterController.java | 48 +- .../FolderAccessSettingsController.java | 17 +- .../policy/controller/PolicyController.java | 417 +++++++++++++----- .../policy/controller/PolicyRunRoutes.java | 10 +- .../policy/engine/PolicyEngine.java | 2 + .../policy/engine/PolicyRunner.java | 12 +- .../policy/input/S3InputSource.java | 13 +- .../policy/ledger/JpaProcessedLedger.java | 11 +- .../policy/ledger/ProcessedFileEntity.java | 17 +- .../ledger/ProcessedFileRepository.java | 303 +++++++------ .../CompletedMigrationRepository.java | 29 +- .../network/NetworkConnectionResolver.java | 2 +- .../output/PolicyInlineOutputMigration.java | 27 +- .../policy/output/S3OutputSink.java | 13 +- .../overview/PolicyOverviewService.java | 3 + .../s3/EmbeddedS3CredentialMigration.java | 28 +- .../s3/PolicyS3ConnectionUsageCheck.java | 3 + .../policy/s3/S3ConnectionResolver.java | 5 +- .../DefaultClassificationPolicySeeder.java | 26 +- .../FolderAccessDeniedExceptionMapper.java | 43 ++ .../policy/source/JpaSourceDocCounter.java | 3 +- .../policy/source/JpaSourceStore.java | 9 +- .../policy/source/SourceController.java | 144 +++--- .../policy/source/SourceDocCountEntity.java | 20 +- .../source/SourceDocCountRepository.java | 91 ++-- .../policy/source/SourceDocTotalEntity.java | 24 +- .../source/SourceDocTotalRepository.java | 46 +- .../policy/source/SourceOverviewService.java | 3 + .../policy/source/SourceRepository.java | 36 +- .../policy/store/JpaPolicyStore.java | 8 +- .../policy/trigger/FolderWatchTrigger.java | 2 + .../policy/trigger/WebhookTrigger.java | 3 + .../webhook/WebhookReceiverController.java | 105 +++-- .../api/AdminLoginAgreementController.java | 47 +- .../api/AdminSettingsController.java | 2 +- .../controller/api/TeamController.java | 9 +- .../filter/JwtAuthenticationFilter.java | 1 + .../filter/UserAuthenticationFilter.java | 35 +- .../filter/UserBasedRateLimitingFilter.java | 8 +- .../ApiKeyDailyUsageRepository.java | 141 ++++-- .../security/repository/ApiKeyRepository.java | 35 +- .../repository/JwtSigningKeyRepository.java | 24 +- .../repository/TeamMembershipRepository.java | 10 + .../CustomSaml2AuthenticatedPrincipal.java | 9 - .../service/ApiKeyAuthenticationService.java | 18 +- .../service/ApiKeyLegacyMigrator.java | 7 +- .../service/ApiKeyManagementService.java | 37 +- .../security/service/ApiKeyUsageRecorder.java | 29 +- .../security/service/ApiKeyUsageWriter.java | 10 +- .../service/KeyPersistenceService.java | 2 +- .../service/TeamMembershipService.java | 10 +- .../service/AiDocumentHtmlRenderer.java | 5 +- .../service/AiEngineConfigSync.java | 10 +- .../proprietary/service/AiFeatureGate.java | 10 +- .../service/PortalAuditReadService.java | 27 +- .../storage/config/StorageProviderConfig.java | 7 +- .../crypto/EncryptingStorageProvider.java | 5 + .../storage/service/FileStorageService.java | 6 +- .../workflow/dto/SignDocumentRequest.java | 13 +- .../service/WorkflowSessionService.java | 12 +- .../InstanceEntitlementGateTest.java | 8 +- .../api/ClassifyLabelControllerTest.java | 15 +- .../api/PdfCommentAgentControllerTest.java | 42 +- .../api/ProprietaryUIDataControllerTest.java | 33 +- .../policy/config/FolderAccessGuardTest.java | 34 +- .../policy/engine/PolicyExecutorTest.java | 12 +- .../policy/input/FolderInputSourceTest.java | 16 +- .../policy/output/FolderOutputSinkTest.java | 39 +- .../s3/EmbeddedS3CredentialMigrationTest.java | 6 +- .../policy/source/JpaSourceStoreTest.java | 16 +- .../controller/api/UserControllerTest.java | 285 +++++++++++- .../ApiKeyAuthenticationServiceTest.java | 6 +- .../service/ApiKeyUsageRecorderTest.java | 13 +- .../service/TeamMembershipServiceTest.java | 14 +- .../security/service/UserServiceTest.java | 7 +- .../service/AiWorkflowServiceTest.java | 8 +- .../service/WorkflowSessionServiceTest.java | 10 +- 119 files changed, 2910 insertions(+), 1555 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/FolderAccessDeniedExceptionMapper.java diff --git a/QUARKUS_MIGRATION_TODO.md b/QUARKUS_MIGRATION_TODO.md index b5318301fa..228382b84e 100644 --- a/QUARKUS_MIGRATION_TODO.md +++ b/QUARKUS_MIGRATION_TODO.md @@ -1,165 +1,129 @@ # Quarkus migration TODO -The Spring Boot -> Quarkus port of this branch is not finished. This file is the single backlog for -what is left. The per-file `TODO: Migration required` comments that used to carry this information -have been removed from the source and folded in here. Companion docs: -`QUARKUS_MIGRATION_HANDOFF.md` (how to work on the migration - stack, commands, patterns) and -`migration-report.md` (the original summary). +Backlog for finishing the Spring Boot -> Quarkus port of this branch. The per-file +`TODO: Migration required` comments that used to carry this information were removed from the +source and folded in here. Companion docs: `QUARKUS_MIGRATION_HANDOFF.md` (stack, commands, +patterns) and `migration-report.md` (the original summary). ## Status -| Module | `compileJava` | Notes | -|---|---|---| -| `:common` | clean | no Spring imports left in main sources | -| `:stirling-pdf` (`app/core`) | clean | no Spring imports left in main sources | -| `:proprietary` | **fails** | see [Blocking compile errors](#blocking-compile-errors) | -| `:saas` | not measured | opt-in flavor (`STIRLING_FLAVOR=saas`), sits on top of `:proprietary` | +| Module | main sources | test sources | notes | +|---|---|---|---| +| `:common` | compiles | compiles | no Spring imports | +| `:stirling-pdf` (`app/core`) | compiles | compiles | no Spring imports | +| `:proprietary` | **compiles** | **fails** | see [Test layer](#test-layer) | +| `:saas` | not measured | not measured | opt-in flavor, 21 files still on Spring | -`:proprietary` is compiled by **every** flavor - `settings.gradle` always includes it and only -`:saas` is conditional - so `./gradlew build` cannot pass on any flavor, `core` included, until the -list below is empty. +The **proprietary (default) flavor builds, boots and serves**: -## Blocking compile errors +```bash +STIRLING_FLAVOR=proprietary ./gradlew :stirling-pdf:quarkusBuild -PnoSpotless +``` -`STIRLING_FLAVOR=proprietary ./gradlew :proprietary:compileJava` reports **1266 errors across -70 files**. javac stops printing after 100 errors by default; to see them all, temporarily -add `options.compilerArgs << '-Xmaxerrs' << '20000'` to the `JavaCompile` tasks. +```bash +java -jar app/core/build/stirling-pdf-*-runner.jar +``` -Two things worth knowing before working through this list: +Verified on that jar: Quarkus augmentation clean, **0 errors during startup**, and +`rotate-pdf`, `get-info-on-pdf` and `compress-pdf` all return valid output over HTTP. +Its OpenAPI document carries **333 operations over 315 paths**, against main's 294/277. -- **Lombok bails on the first hard javac error and takes every generated member with it.** One - malformed annotation used to produce ~5500 extra `cannot find symbol: variable log` / - `cannot find symbol: method getX()` errors in unrelated files. If the count jumps, look for a - single real error first rather than trusting the total. -- Fix the shared types (repositories, resolvers) before their callers; a lot of `cannot find symbol` - in a controller is really its repository failing to compile. +`./gradlew build` still fails, because `:proprietary` test sources do not compile yet - and note +`settings.gradle` includes `:proprietary` on *every* flavor, so that blocks the `core` leg too. -Grouped by the port each file needs, largest group first. +## Test layer -### Spring MVC REST controllers -> JAX-RS (16 files, 682 errors) +`STIRLING_FLAVOR=proprietary ./gradlew :proprietary:compileTestJava` fails in 16 files. +The main-source port moved signatures the tests still assert against. The three shapes: -`@RestController` + `@RequestMapping` -> `@ApplicationScoped` + `@Path`; `@GetMapping`/`@PostMapping`/`@PutMapping`/`@DeleteMapping` -> `@GET`/`@POST`/`@PUT`/`@DELETE` + `@Path`; `ResponseEntity` -> `jakarta.ws.rs.core.Response`; `@RequestBody` -> a plain parameter; `@RequestParam` -> `@QueryParam` (or `@RestForm` for multipart); `@PathVariable` -> `@PathParam`; `@RequestHeader` -> `@HeaderParam`; `@ModelAttribute`/`@RequestPart` -> `@BeanParam`/`@RestForm`; `@AuthenticationPrincipal` -> injected `SecurityIdentity`. Exemplar: `controller/api/AdminJobController.java`. +- **`findById` stubs.** Spring Data returned `Optional`; Panache's inherited `findById` returns + a nullable entity and `findByIdOptional` returns the `Optional`. Production call sites moved to + `findByIdOptional`, so `when(repo.findById(id)).thenReturn(Optional.of(x))` has to follow. +- **A missing `save()` shim, which is a production bug, not a test bug.** + `WorkflowParticipantRepository` and `FileEncryptionKeyRepository` are Panache repositories that + never regained the `save(E)` that main's `JpaRepository` supplied for free. Add the shim rather + than editing the tests around it. +- **`Environment` mocks.** `TeamMembershipService` moved to MicroProfile Config; + `TeamMembershipServiceTest` still mocks Spring's `Environment` - and does so *fully qualified*, + so the import-based test exclusion filter does not catch it. -- [ ] `proprietary/policy/controller/PolicyController.java` - 130 errors -- [ ] `proprietary/integration/controller/IntegrationConfigController.java` - 76 errors -- [ ] `proprietary/integration/api/ExternalApiCallController.java` - 68 errors -- [ ] `proprietary/policy/source/SourceController.java` - 64 errors -- [ ] `proprietary/access/controller/ResourceGrantController.java` - 54 errors -- [ ] `proprietary/accountlink/AccountLinkController.java` - 50 errors -- [ ] `proprietary/integration/purview/PurviewLabelController.java` - 48 errors -- [ ] `proprietary/security/controller/api/AdminLoginAgreementController.java` - 34 errors -- [ ] `proprietary/controller/api/PortalApiKeysController.java` - 32 errors -- [ ] `proprietary/policy/webhook/WebhookReceiverController.java` - 32 errors -- [ ] `proprietary/policy/controller/ClassificationMeterController.java` - 26 errors -- [ ] `proprietary/controller/api/ClassifyLabelController.java` - 24 errors -- [ ] `proprietary/controller/api/PortalDocumentsController.java` - 14 errors -- [ ] `proprietary/controller/api/PortalInfraAuditController.java` - 14 errors -- [ ] `proprietary/controller/api/FleetUsageController.java` - 12 errors -- [ ] `proprietary/policy/controller/FolderAccessSettingsController.java` - 4 errors +- [ ] `proprietary/accountlink/InstanceEntitlementGateTest.java` - 2 distinct error(s) +- [ ] `proprietary/controller/api/PdfCommentAgentControllerTest.java` - 1 distinct error(s) +- [ ] `proprietary/controller/api/ProprietaryUIDataControllerTest.java` - 1 distinct error(s) +- [ ] `proprietary/policy/config/FolderAccessGuardTest.java` - 2 distinct error(s) +- [ ] `proprietary/policy/engine/PolicyExecutorTest.java` - 1 distinct error(s) +- [ ] `proprietary/policy/input/FolderInputSourceTest.java` - 1 distinct error(s) +- [ ] `proprietary/policy/output/FolderOutputSinkTest.java` - 2 distinct error(s) +- [ ] `proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java` - 1 distinct error(s) +- [ ] `proprietary/policy/source/JpaSourceStoreTest.java` - 2 distinct error(s) +- [ ] `proprietary/security/controller/api/UserControllerTest.java` - 1 distinct error(s) +- [ ] `proprietary/security/service/ApiKeyAuthenticationServiceTest.java` - 1 distinct error(s) +- [ ] `proprietary/security/service/TeamMembershipServiceTest.java` - 1 distinct error(s) +- [ ] `proprietary/security/service/UserServiceTest.java` - 1 distinct error(s) +- [ ] `proprietary/service/AiWorkflowServiceTest.java` - 1 distinct error(s) +- [ ] `proprietary/storage/crypto/InMemoryKeyRepo.java` - 1 distinct error(s) +- [ ] `proprietary/workflow/service/WorkflowSessionServiceTest.java` - 1 distinct error(s) -### Spring Data JPA repositories -> Panache (14 files, 328 errors) +## `:saas` flavor -`extends JpaRepository` -> `implements PanacheRepositoryBase` on an `@ApplicationScoped` class; derived finders become explicit `find`/`list` calls; `@Query`/`@Modifying`/`@Param` become Panache `find(...)`/`update(...)` with `Parameters.with(...)`; `findById` -> `findByIdOptional`; add a `save()` shim where callers expect Spring Data's. Exemplars: `policy/store/PolicyRepository.java`, `repository/PersistentAuditEventRepository.java`. +21 files still import Spring. Same three tiers as the proprietary port, so the same +recipes apply. `:saas` sits on `:proprietary`, so it could not be measured until now. -- [ ] `proprietary/policy/ledger/ProcessedFileRepository.java` - 136 errors -- [ ] `proprietary/security/repository/ApiKeyDailyUsageRepository.java` - 42 errors -- [ ] `proprietary/policy/source/SourceDocCountRepository.java` - 38 errors -- [ ] `proprietary/accountlink/UsageCounterRepository.java` - 34 errors -- [ ] `proprietary/policy/source/SourceDocTotalRepository.java` - 22 errors -- [ ] `proprietary/access/repository/ResourceGrantRepository.java` - 16 errors -- [ ] `proprietary/policy/source/SourceRepository.java` - 12 errors -- [ ] `proprietary/integration/repository/IntegrationConfigRepository.java` - 4 errors -- [ ] `proprietary/security/repository/ApiKeyRepository.java` - 4 errors -- [ ] `proprietary/accountlink/AccountLinkSyncStateRepository.java` - 4 errors -- [ ] `proprietary/accountlink/DeviceCredentialRepository.java` - 4 errors -- [ ] `proprietary/accountlink/MeteredInputSignatureRepository.java` - 4 errors -- [ ] `proprietary/policy/migration/CompletedMigrationRepository.java` - 4 errors -- [ ] `proprietary/security/repository/JwtSigningKeyRepository.java` - 4 errors +### Spring Data repositories -> Panache (5) -### Spring MVC infrastructure (interceptors, WebMvcConfigurer) (3 files, 34 errors) +- [ ] `saas/accountlink/LinkedInstanceRepository.java` +- [ ] `saas/payg/bundle/PrepaidBundleRepository.java` +- [ ] `saas/payg/repository/PaygInstanceUsageRepository.java` +- [ ] `saas/procurement/repository/ProcurementDealRepository.java` +- [ ] `saas/procurement/repository/ProcurementQuoteRepository.java` -`HandlerInterceptor` / `WebMvcConfigurer` / `HandlerMapping` / `WebUtils` have no Quarkus equivalent. Re-implement as a JAX-RS `@Provider ContainerRequestFilter` (or a Vert.x route filter for non-JAX-RS paths) and delete the MVC registration class. +### Services and config (9) -- [ ] `proprietary/accountlink/InstanceEntitlementInterceptor.java` - 20 errors -- [ ] `proprietary/accountlink/AccountLinkWebMvcConfig.java` - 12 errors -- [ ] `proprietary/policy/controller/PolicyRunRoutes.java` - 2 errors +- [ ] `saas/accountlink/AccountLinkService.java` +- [ ] `saas/accountlink/LinkedInstanceAuthenticationToken.java` +- [ ] `saas/model/SaasUserExtensions.java` +- [ ] `saas/payg/instance/InstanceUsageIngestService.java` +- [ ] `saas/payg/stripe/StripeInvoiceDao.java` +- [ ] `saas/payg/stripe/StripePaymentMethodDao.java` +- [ ] `saas/procurement/license/KeygenEnterpriseLicenseService.java` +- [ ] `saas/procurement/license/MockEnterpriseLicenseService.java` +- [ ] `saas/security/SaasPortalAuditScopeResolver.java` -### ResponseStatusException -> WebApplicationException (4 files, 26 errors) +### REST controllers and filters (7) -`ResponseStatusException(HttpStatus.X, msg)` -> `jakarta.ws.rs.WebApplicationException(msg, Response.Status.X)`; `HttpStatus` -> `Response.Status`. +- [ ] `saas/accountlink/AccountLinkController.java` +- [ ] `saas/accountlink/DeviceCredentialAuthenticationFilter.java` +- [ ] `saas/accountlink/InstanceController.java` +- [ ] `saas/payg/api/PaygInvoicesController.java` +- [ ] `saas/payg/api/PaygPaymentMethodController.java` +- [ ] `saas/procurement/api/ProcurementController.java` +- [ ] `saas/usage/SaasFleetUsageController.java` -- [ ] `proprietary/access/service/OwnershipService.java` - 10 errors -- [ ] `proprietary/integration/service/IntegrationConfigService.java` - 8 errors -- [ ] `proprietary/service/AiFeatureGate.java` - 4 errors -- [ ] `proprietary/security/service/ApiKeyManagementService.java` - 4 errors +## Parity gaps against main -### Conditional beans (9 files, 56 errors) +Measured by diffing the OpenAPI document of a booted `origin/main` (Spring, proprietary flavor) +against a booted branch jar of the same flavor. -`@ConditionalOnProperty` / `@ConditionalOnMissingBean` gate on runtime config, which Arc cannot do at build time. Branch convention: keep the bean unconditional and guard at the call site on `ApplicationProperties`, or use `@io.quarkus.arc.lookup.LookupIfProperty` / `@IfBuildProfile` once the flag can become build-time. +- **The automation/policy stack is unavailable on the proprietary flavor.** Every policy bean + carries `@IfBuildProfile("saas")` - a pre-existing branch decision, not something main does: + main serves policies on proprietary. Arc turns that gate into `@Vetoed` off the saas profile, so + each consumer needs the same gate or augmentation fails; 11 controllers/services were gated to + make the flavor build. The visible symptom in the spec diff is one missing operation, + `GET /api/v1/admin/settings/policies/implied-folder-roots`, but the whole subsystem is off. + Deciding whether policies should run on proprietary is an owner call, not a mechanical port. +- **`policies.streamTimeoutMs` is ignored.** `/api/v1/policies/run-stream` used Spring's + `SseEmitter(timeout)`; JAX-RS SSE has no per-sink deadline, so the stream is now bounded by the + container's HTTP idle timeout instead of the configured 30 minutes. +- **Multipart parameters no longer bind from the query string.** Spring's `@RequestParam` read + both the query string and the form body; `@RestForm` reads only the body. 20 operations are + affected. A client that passed these as query parameters on a multipart POST would now get a + null. The frontend sends them as form fields, so this is a compatibility narrowing for API + callers rather than a broken feature. -- [ ] `proprietary/accountlink/UsageSyncService.java` - 14 errors -- [ ] `proprietary/access/config/AccessConfig.java` - 10 errors -- [ ] `proprietary/accountlink/DeviceCredentialStore.java` - 8 errors -- [ ] `proprietary/accountlink/AccountLinkClient.java` - 4 errors -- [ ] `proprietary/accountlink/AccountLinkService.java` - 4 errors -- [ ] `proprietary/accountlink/LocalUsageService.java` - 4 errors -- [ ] `proprietary/accountlink/EntitlementCache.java` - 4 errors -- [ ] `proprietary/accountlink/InstanceEntitlementGate.java` - 4 errors -- [ ] `proprietary/accountlink/UsageMeterService.java` - 4 errors - -### Spring lifecycle events (5 files, 52 errors) - -`@EventListener(ApplicationReadyEvent|ContextRefreshedEvent)` -> `void onStart(@Observes StartupEvent ev)`; `@TransactionalEventListener` -> an explicit call after the transaction commits; `ApplicationEventPublisher` -> CDI `Event`. - -- [ ] `proprietary/policy/seed/DefaultClassificationPolicySeeder.java` - 16 errors -- [ ] `proprietary/policy/output/PolicyInlineOutputMigration.java` - 10 errors -- [ ] `proprietary/policy/s3/EmbeddedS3CredentialMigration.java` - 10 errors -- [ ] `proprietary/policy/ledger/JpaProcessedLedger.java` - 8 errors -- [ ] `proprietary/service/AiEngineConfigSync.java` - 8 errors - -### Paging / sorting / Persistable (4 files, 24 errors) - -`Pageable`/`PageRequest`/`Sort`/`Page` -> Panache `page(Page.of(n, size))` + `io.quarkus.panache.common.Sort`; `Persistable` -> drop it (Panache decides insert vs update from the id). - -- [ ] `proprietary/service/PortalAuditReadService.java` - 12 errors -- [ ] `proprietary/policy/ledger/ProcessedFileEntity.java` - 4 errors -- [ ] `proprietary/policy/source/SourceDocCountEntity.java` - 4 errors -- [ ] `proprietary/policy/source/SourceDocTotalEntity.java` - 4 errors - -### Scheduling (1 files, 4 errors) - -`@Scheduled` / `SchedulingConfigurer` / `FixedDelayTask` -> `io.quarkus.scheduler.Scheduled(every = "...")`; a dynamic registrar becomes a `@Scheduled` method reading its interval from config. - -- [ ] `proprietary/security/service/ApiKeyUsageRecorder.java` - 4 errors - -### Transaction propagation (2 files, 20 errors) - -`@Transactional(propagation = REQUIRES_NEW)` -> `jakarta.transaction.Transactional(REQUIRES_NEW)` or `QuarkusTransaction.requiringNew()`; `readOnly` has no jakarta equivalent and is dropped. - -- [ ] `proprietary/security/service/ApiKeyUsageWriter.java` - 14 errors -- [ ] `proprietary/security/service/ApiKeyLegacyMigrator.java` - 6 errors - -### Spring HTTP MediaType (2 files, 6 errors) - -`org.springframework.http.MediaType` / `MediaTypeFactory` -> `jakarta.ws.rs.core.MediaType` plus an explicit extension-to-type map. - -- [ ] `proprietary/policy/output/S3OutputSink.java` - 4 errors -- [ ] `proprietary/integration/api/ApiTokenCache.java` - 2 errors - -### Remaining assorted Spring types (10 files, 34 errors) - -Assorted remaining Spring types; each file's imports name what it needs. - -- [ ] `proprietary/security/service/TeamMembershipService.java` - 8 errors -- [ ] `proprietary/model/TeamEntityListener.java` - 6 errors -- [ ] `proprietary/policy/input/S3InputSource.java` - 4 errors -- [ ] `proprietary/policy/source/JpaSourceDocCounter.java` - 4 errors -- [ ] `proprietary/accountlink/AccountLinkProperties.java` - 2 errors -- [ ] `proprietary/integration/api/ResultFiles.java` - 2 errors -- [ ] `proprietary/access/service/ResourceAccessService.java` - 2 errors -- [ ] `proprietary/access/security/ResourceAccessSecurity.java` - 2 errors -- [ ] `proprietary/integration/api/ApiConnectionResolver.java` - 2 errors -- [ ] `proprietary/policy/s3/S3ConnectionResolver.java` - 2 errors +Everything else in the spec diff is benign: 97 operations where main published an opaque request +body and the branch now describes the individual multipart fields, 20 same-name relocations from +the change above, and 40 branch-only routes (static/SPA paths, MCP, mobile-scanner, AI) that +springdoc did not document. **No operation loses a parameter outright.** ## Deferred behaviour diff --git a/app/common/src/main/java/stirling/software/common/model/io/InputStreamResource.java b/app/common/src/main/java/stirling/software/common/model/io/InputStreamResource.java index da1ab6ceb8..507bb2c6f8 100644 --- a/app/common/src/main/java/stirling/software/common/model/io/InputStreamResource.java +++ b/app/common/src/main/java/stirling/software/common/model/io/InputStreamResource.java @@ -43,6 +43,11 @@ public class InputStreamResource implements Resource { return -1; } + @Override + public boolean isOpen() { + return true; + } + @Override public File getFile() throws IOException { throw new IOException("InputStreamResource is not backed by a file"); diff --git a/app/common/src/main/java/stirling/software/common/model/io/Resource.java b/app/common/src/main/java/stirling/software/common/model/io/Resource.java index 9d011b314f..f522c6b9b4 100644 --- a/app/common/src/main/java/stirling/software/common/model/io/Resource.java +++ b/app/common/src/main/java/stirling/software/common/model/io/Resource.java @@ -10,7 +10,7 @@ import java.io.InputStream; *

Quarkus/Jakarta has no single {@code Resource} abstraction. Rather than rewrite the many * public method signatures across the codebase that accept or return {@code Resource}, this * interface mirrors the subset of Spring's API the codebase actually uses ({@code - * getInputStream/exists/getFile/getFilename/contentLength/isFile}) together with the {@link + * getInputStream/exists/getFile/getFilename/contentLength/isFile/isOpen}) together with the {@link * FileSystemResource}, {@link InputStreamResource} and {@link ClassPathResource} implementations. * Converting a file is then just an import swap. */ @@ -29,6 +29,14 @@ public interface Resource { return false; } + /** + * Whether this resource wraps an already-open stream, so {@link #getInputStream()} can only be + * read once and must be consumed or closed to avoid a leak. + */ + default boolean isOpen() { + return false; + } + /** * @return the underlying file. * @throws IOException if the resource is not file-backed. diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index c980883b0a..88069a1628 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -101,6 +101,11 @@ mp.openapi.filter=stirling.software.SPDF.config.ToolModelSchemaCustomizer # ---- Jackson (was spring.jackson.*) ---------------------------------------------------------- # spring.jackson.deserialization.fail-on-null-for-primitives=false +# ---- Caches (was CacheConfig's CaffeineCacheManager.registerCustomCache) --------------------- +# 30s TTL keeps audit views near-live without re-scanning the DB; one entry per scope. +quarkus.cache.caffeine."portalAuditEvents".maximum-size=256 +quarkus.cache.caffeine."portalAuditEvents".expire-after-write=30S + # ---- Logging (was logging.level.*) ----------------------------------------------------------- quarkus.log.category."org.springframework".level=WARN quarkus.log.category."org.hibernate".level=WARN diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 6df3d43ea5..fbea64572e 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -107,10 +107,6 @@ dependencies { // Streaming AEAD (AES-GCM-HKDF segments) for storage encryption at rest. Apache-2.0. implementation "com.google.crypto.tink:tink:${tinkVersion}" - // @DataJpaTest slice (Boot 4 ships test slices as separate starters, like webmvc-test at the - // root) so policy.source repositories can be exercised against embedded H2. - testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - // Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without // manually-started instances. Tests skip cleanly when Docker is unavailable. testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java index 5afbb5f9d1..b7aa5ff85b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java @@ -1,8 +1,9 @@ package stirling.software.proprietary.access.config; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import io.quarkus.arc.DefaultBean; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; import stirling.software.proprietary.access.service.DefaultPrincipalResolver; import stirling.software.proprietary.access.service.DefaultTeamLeadLookup; @@ -14,16 +15,18 @@ import stirling.software.proprietary.access.service.TeamLeadLookup; public class AccessConfig { /** No-op {@link TeamLeadLookup} unless another bean is defined. */ - @Bean - @ConditionalOnMissingBean(TeamLeadLookup.class) - TeamLeadLookup defaultTeamLeadLookup() { + @Produces + @DefaultBean + @ApplicationScoped + public TeamLeadLookup defaultTeamLeadLookup() { return new DefaultTeamLeadLookup(); } /** USER/TEAM projection unless another bean is defined (e.g. the saas resolver). */ - @Bean - @ConditionalOnMissingBean(PrincipalResolver.class) - PrincipalResolver defaultPrincipalResolver() { + @Produces + @DefaultBean + @ApplicationScoped + public PrincipalResolver defaultPrincipalResolver() { return new DefaultPrincipalResolver(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java index c5eed42488..b2d1099135 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java @@ -1,23 +1,27 @@ package stirling.software.proprietary.access.controller; +import java.security.Principal; import java.util.HashMap; import java.util.List; import java.util.Map; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - +import io.quarkus.security.identity.SecurityIdentity; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; @@ -31,8 +35,8 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; /** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */ -@RestController -@RequestMapping("/api/v1/admin/access") +@ApplicationScoped +@Path("/api/v1/admin/access") @RequiredArgsConstructor @RolesAllowed("ADMIN") @Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)") @@ -42,48 +46,67 @@ public class ResourceGrantController { private final UserRepository userRepository; private final TeamRepository teamRepository; - @GetMapping("/grants") - public ResponseEntity list( - @RequestParam ResourceType resourceType, - @RequestParam(required = false, defaultValue = "") String resourceId) { + // Quarkus stand-in for @AuthenticationPrincipal: UserSecurityIdentityAugmentor attaches the + // User entity to SecurityIdentity. Field injection keeps the @RequiredArgsConstructor stable. + @Inject SecurityIdentity securityIdentity; + + @GET + @Path("/grants") + @Produces(MediaType.APPLICATION_JSON) + public Response list( + @QueryParam("resourceType") ResourceType resourceType, + @QueryParam("resourceId") @DefaultValue("") String resourceId) { + requireParam(resourceType, "resourceType", "ResourceType"); List grants = accessService.listGrants(resourceType, resourceId); - return ResponseEntity.ok(grants.stream().map(this::toDto).toList()); + return Response.ok(grants.stream().map(this::toDto).toList()).build(); } - @GetMapping("/grants/by-principal") - public ResponseEntity listByPrincipal( - @RequestParam PrincipalType principalType, @RequestParam Long principalId) { + @GET + @Path("/grants/by-principal") + @Produces(MediaType.APPLICATION_JSON) + public Response listByPrincipal( + @QueryParam("principalType") PrincipalType principalType, + @QueryParam("principalId") Long principalId) { + requireParam(principalType, "principalType", "PrincipalType"); + requireParam(principalId, "principalId", "Long"); List grants = accessService.listGrantsForPrincipal(principalType, principalId); - return ResponseEntity.ok(grants.stream().map(this::toDto).toList()); + return Response.ok(grants.stream().map(this::toDto).toList()).build(); } - @PostMapping("/grants") - public ResponseEntity create( - @RequestBody GrantRequest request, @AuthenticationPrincipal User admin) { - if (request.resourceType() == null + @POST + @Path("/grants") + @Produces(MediaType.APPLICATION_JSON) + public Response create(GrantRequest request) { + if (request == null + || request.resourceType() == null || request.principalType() == null || request.principalId() == null) { - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", - "resourceType, principalType and principalId are required")); + "resourceType, principalType and principalId are required")) + .build(); } // PORTAL is a singleton (empty resourceId); every other type must name a resource. boolean portal = request.resourceType() == ResourceType.PORTAL; if (!portal && (request.resourceId() == null || request.resourceId().isBlank())) { - return ResponseEntity.badRequest() - .body(Map.of("error", "resourceId is required for " + request.resourceType())); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "resourceId is required for " + request.resourceType())) + .build(); } Long principalId = request.principalId(); String principalError = validatePrincipalExists(request.principalType(), principalId); if (principalError != null) { - return ResponseEntity.badRequest().body(Map.of("error", principalError)); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", principalError)) + .build(); } AccessPermission permission = request.permission() == null ? AccessPermission.USE : request.permission(); String resourceId = portal ? "" : request.resourceId(); + User admin = currentUser(); ResourceGrant grant = accessService.grant( request.resourceType(), @@ -92,23 +115,48 @@ public class ResourceGrantController { principalId, permission, admin); - return ResponseEntity.ok(toDto(grant)); + return Response.ok(toDto(grant)).build(); } - @DeleteMapping("/grants/{id}") - public ResponseEntity delete(@PathVariable Long id) { + @DELETE + @Path("/grants/{id}") + @Produces(MediaType.APPLICATION_JSON) + public Response delete(@PathParam("id") Long id) { accessService.revoke(id); - return ResponseEntity.ok(Map.of("message", "Grant revoked")); + return Response.ok(Map.of("message", "Grant revoked")).build(); } - // Rejects grants to nonexistent principals (dead rows otherwise). + // Rejects grants to nonexistent principals (dead rows otherwise). Panache has no existsById, + // so the existence probe is a count by id. private String validatePrincipalExists(PrincipalType type, Long id) { return switch (type) { - case USER -> userRepository.existsById(id) ? null : "User " + id + " does not exist"; - case TEAM -> teamRepository.existsById(id) ? null : "Team " + id + " does not exist"; + case USER -> + userRepository.count("id", id) > 0 ? null : "User " + id + " does not exist"; + case TEAM -> + teamRepository.count("id", id) > 0 ? null : "Team " + id + " does not exist"; }; } + // Null when the principal is not a User entity, matching what @AuthenticationPrincipal bound; + // ResourceAccessService.grant leaves grantedBy untouched in that case. + private User currentUser() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return null; + } + Principal principal = securityIdentity.getPrincipal(); + return principal instanceof User user ? user : null; + } + + // A missing query param binds to null under JAX-RS, where Spring rejected a required + // @RequestParam with 400; the status and wording are reproduced here. + private static void requireParam(Object value, String name, String type) { + if (value == null) { + throw new WebApplicationException( + "Required parameter '" + name + "' of type '" + type + "' is missing", + Response.Status.BAD_REQUEST); + } + } + private Map toDto(ResourceGrant g) { Map m = new HashMap<>(); m.put("id", g.getId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java index ae13f1f429..c6125c6c63 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java @@ -2,12 +2,11 @@ package stirling.software.proprietary.access.repository; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import stirling.software.proprietary.access.model.PrincipalType; import stirling.software.proprietary.access.model.ResourceGrant; @@ -15,31 +14,66 @@ import stirling.software.proprietary.access.model.ResourceType; import stirling.software.proprietary.security.model.User; @ApplicationScoped -public interface ResourceGrantRepository extends JpaRepository { +public class ResourceGrantRepository implements PanacheRepositoryBase { - List findByResourceTypeAndResourceId( - ResourceType resourceType, String resourceId); + public List findByResourceTypeAndResourceId( + ResourceType resourceType, String resourceId) { + return list("resourceType = ?1 and resourceId = ?2", resourceType, resourceId); + } - List findByResourceTypeAndPrincipalTypeAndPrincipalId( - ResourceType resourceType, PrincipalType principalType, Long principalId); + public List findByResourceTypeAndPrincipalTypeAndPrincipalId( + ResourceType resourceType, PrincipalType principalType, Long principalId) { + return list( + "resourceType = ?1 and principalType = ?2 and principalId = ?3", + resourceType, + principalType, + principalId); + } /** All grants held by a principal, across resource types (for the manage-access view). */ - List findByPrincipalTypeAndPrincipalId( - PrincipalType principalType, Long principalId); + public List findByPrincipalTypeAndPrincipalId( + PrincipalType principalType, Long principalId) { + return list("principalType = ?1 and principalId = ?2", principalType, principalId); + } - void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId); + @Transactional + public void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId) { + delete("resourceType = ?1 and resourceId = ?2", resourceType, resourceId); + } /** Removes every grant held by a principal; used when the user/team behind it is deleted. */ - void deleteByPrincipalTypeAndPrincipalId(PrincipalType principalType, Long principalId); + @Transactional + public void deleteByPrincipalTypeAndPrincipalId(PrincipalType principalType, Long principalId) { + delete("principalType = ?1 and principalId = ?2", principalType, principalId); + } // Detach issued grants so deleting the granting user does not hit the FK. - @Modifying - @Query("update ResourceGrant g set g.grantedBy = null where g.grantedBy = :user") - void clearGrantedBy(@Param("user") User user); + @Transactional + public void clearGrantedBy(User user) { + update( + "update ResourceGrant g set g.grantedBy = null where g.grantedBy = :user", + Parameters.with("user", user)); + } - boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId( + public boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId( ResourceType resourceType, String resourceId, PrincipalType principalType, - Long principalId); + Long principalId) { + return count( + "resourceType = ?1 and resourceId = ?2 and principalType = ?3 and" + + " principalId = ?4", + resourceType, + resourceId, + principalType, + principalId) + > 0; + } + + /** Spring Data {@code save}: inserts a new grant, dirty-checks a managed one. */ + @Transactional + public ResourceGrant save(ResourceGrant grant) { + persist(grant); + return grant; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java index b211d47aae..19381062c8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.access.security; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Named; import lombok.RequiredArgsConstructor; @@ -17,7 +18,8 @@ import stirling.software.proprietary.security.service.UserService; * {@code @PreAuthorize("@resourceAccess.canUsePortal()")}; endpoints shared with the editor (e.g. * the policies API) must NOT be. */ -@ApplicationScoped("resourceAccess") +@ApplicationScoped +@Named("resourceAccess") @RequiredArgsConstructor public class ResourceAccessSecurity { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java index 28ae4205d0..52033eff5f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java @@ -3,11 +3,10 @@ package stirling.software.proprietary.access.service; import java.util.Set; import java.util.function.BooleanSupplier; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; - import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; @@ -22,7 +21,8 @@ import stirling.software.proprietary.security.repository.TeamRepository; /** Ownership and access checks for {@link OwnedResource}, backed by the resource-grant ACL. */ @ApplicationScoped @RequiredArgsConstructor -@Transactional(readOnly = true) +// jakarta.transaction.Transactional has no readOnly hint; the reads are unchanged without it. +@Transactional public class OwnershipService { private final ResourceAccessService accessService; @@ -77,12 +77,12 @@ public class OwnershipService { } case TEAM -> { if (teamId == null) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "ownerTeamId is required"); + throw new WebApplicationException( + "ownerTeamId is required", Response.Status.BAD_REQUEST); } Team team = teamRepository - .findById(teamId) + .findByIdOptional(teamId) .orElseThrow(() -> notFound("Team not found")); if (!isAdmin(user) && !teamLeadLookup.isLeaderOfTeam(user, team.getId())) { throw forbidden("Only admins or team leaders can create team-owned resources"); @@ -111,11 +111,11 @@ public class OwnershipService { && teamLeadLookup.isLeaderOfTeam(user, resource.getOwnerTeamId()); } - private ResponseStatusException forbidden(String message) { - return new ResponseStatusException(HttpStatus.FORBIDDEN, message); + private WebApplicationException forbidden(String message) { + return new WebApplicationException(message, Response.Status.FORBIDDEN); } - private ResponseStatusException notFound(String message) { - return new ResponseStatusException(HttpStatus.NOT_FOUND, message); + private WebApplicationException notFound(String message) { + return new WebApplicationException(message, Response.Status.NOT_FOUND); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java index 142e9accf2..7688c42897 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java @@ -27,7 +27,8 @@ import stirling.software.proprietary.security.model.User; @ApplicationScoped @RequiredArgsConstructor @Slf4j -@Transactional(readOnly = true) +// jakarta.transaction.Transactional has no readOnly hint; the reads are unchanged without it. +@Transactional public class ResourceAccessService { private final ResourceGrantRepository grantRepository; @@ -35,7 +36,7 @@ public class ResourceAccessService { private final PrincipalResolver principalResolver; @ConfigProperty(name = "security.portal.defaultAccess", defaultValue = "ADMINS_AND_TEAM_LEADS") - private DefaultAccessPolicy portalDefaultPolicy; + DefaultAccessPolicy portalDefaultPolicy; // ---- public checks ---- diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java index 98b5c89501..aad08098be 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -8,8 +8,6 @@ import java.net.http.HttpResponse; import java.time.Duration; import java.time.LocalDateTime; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; @@ -43,10 +41,11 @@ import tools.jackson.databind.node.ObjectNode; *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS. */ +// Arc cannot gate a bean on a runtime property, so the account-link flag no longer removes this +// bean; it holds no state and only its flag-gated callers ever issue a call. @Slf4j @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class AccountLinkClient { static final String HEADER_DEVICE_ID = "X-Device-Id"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java index d8c7716893..7ba0ac3a9b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -2,20 +2,20 @@ package stirling.software.proprietary.accountlink; import java.io.IOException; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - import io.quarkus.arc.profile.IfBuildProfile; import io.swagger.v3.oas.annotations.Hidden; import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; @@ -30,96 +30,120 @@ import lombok.extern.slf4j.Slf4j; * / test aid). * *

Admin-only, {@code @IfBuildProfile("!saas")}, gated behind {@code - * stirling.billing.account-link.enabled} — off → bean absent → 404. + * stirling.billing.account-link.enabled} — off → 404. */ @Slf4j @Hidden -@RestController -@RequestMapping("/api/v1/account-link") +@ApplicationScoped +@Path("/api/v1/account-link") +@Produces(MediaType.APPLICATION_JSON) @IfBuildProfile("!saas") @RolesAllowed("ADMIN") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +// Arc cannot gate a bean on a runtime property and JAX-RS registers the resource regardless, so the +// account-link flag is checked per request instead: off → 404, as bean-absence used to give. public class AccountLinkController { private final AccountLinkService service; private final LocalUsageService localUsageService; - // Present only when metering is on (its own flag); absent → /sync-now reports 409. - private final ObjectProvider syncServiceProvider; + // Metering has its own flag; with it off (or the bean absent) /sync-now reports 409. + private final Instance syncServiceProvider; + private final AccountLinkProperties properties; public AccountLinkController( AccountLinkService service, LocalUsageService localUsageService, - ObjectProvider syncServiceProvider) { + Instance syncServiceProvider, + AccountLinkProperties properties) { this.service = service; this.localUsageService = localUsageService; this.syncServiceProvider = syncServiceProvider; + this.properties = properties; } /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ public record LinkRequest(String supabaseJwt, String name) {} - @PostMapping("/link") - public ResponseEntity link(@RequestBody LinkRequest req) { + @POST + @Path("/link") + @Consumes(MediaType.APPLICATION_JSON) + public Response link(LinkRequest req) { + requireEnabled(); if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) { - return ResponseEntity.badRequest() - .body(java.util.Map.of("error", "supabaseJwt is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(java.util.Map.of("error", "supabaseJwt is required")) + .build(); } try { - return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name())); + return Response.ok(service.link(req.supabaseJwt(), req.name())).build(); } catch (AccountLinkClient.UpstreamException e) { // Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so // the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the // raw upstream body back to the browser. - HttpStatus status = - e.status() == HttpStatus.UNAUTHORIZED.value() - || e.status() == HttpStatus.FORBIDDEN.value() - ? HttpStatus.valueOf(e.status()) - : HttpStatus.BAD_GATEWAY; + Response.Status status = + e.status() == Response.Status.UNAUTHORIZED.getStatusCode() + || e.status() == Response.Status.FORBIDDEN.getStatusCode() + ? Response.Status.fromStatusCode(e.status()) + : Response.Status.BAD_GATEWAY; log.warn("Account-link register rejected upstream: HTTP {}", e.status()); - return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED")); + return Response.status(status).entity(java.util.Map.of("error", "LINK_FAILED")).build(); } catch (IOException e) { // Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the // configured SaaS host/IP. Log it server-side; return the same opaque body the // UpstreamException branch does. log.warn("Account-link failed (transport): {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .body(java.util.Map.of("error", "LINK_FAILED")); + return Response.status(Response.Status.BAD_GATEWAY) + .entity(java.util.Map.of("error", "LINK_FAILED")) + .build(); } } - @GetMapping("/status") - public ResponseEntity status() { - return ResponseEntity.ok(service.status()); + @GET + @Path("/status") + public Response status() { + requireEnabled(); + return Response.ok(service.status()).build(); } - @PostMapping("/unlink") - public ResponseEntity unlink() { + @POST + @Path("/unlink") + public Response unlink() { + requireEnabled(); service.unlink(); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } /** * Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend * so "current usage" includes work done since the last daily sync. */ - @GetMapping("/usage") - public ResponseEntity usage() { - return ResponseEntity.ok(localUsageService.currentPeriodUnsynced()); + @GET + @Path("/usage") + public Response usage() { + requireEnabled(); + return Response.ok(localUsageService.currentPeriodUnsynced()).build(); } /** * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent: * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run; - * {@code 409} when metering is off (the sync bean is absent). + * {@code 409} when metering is off (as the absent sync bean used to report). */ - @PostMapping("/sync-now") - public ResponseEntity syncNow() { - UsageSyncService sync = syncServiceProvider.getIfAvailable(); - if (sync == null) { - return ResponseEntity.status(HttpStatus.CONFLICT).build(); + @POST + @Path("/sync-now") + public Response syncNow() { + requireEnabled(); + if (!properties.getMetering().isEnabled() || !syncServiceProvider.isResolvable()) { + return Response.status(Response.Status.CONFLICT).build(); + } + syncServiceProvider.get().syncNow(); + return Response.noContent().build(); + } + + /** Master flag off: 404 the whole surface, the response main got from the absent bean. */ + private void requireEnabled() { + if (!properties.isEnabled()) { + throw new WebApplicationException(Response.Status.NOT_FOUND); } - sync.syncNow(); - return ResponseEntity.noContent().build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java index 536ae345ea..c1c8627103 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -1,7 +1,13 @@ package stirling.software.proprietary.accountlink; import java.time.Duration; +import java.util.Locale; +import java.util.Optional; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; + +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import lombok.Getter; @@ -18,9 +24,10 @@ import lombok.Setter; @Getter @Setter @ApplicationScoped -@ConfigurationProperties(prefix = "stirling.billing.account-link") public class AccountLinkProperties { + private static final String PREFIX = "stirling.billing.account-link."; + /** Master switch. When {@code false} (default) the feature is fully inert. */ private boolean enabled = false; @@ -41,6 +48,37 @@ public class AccountLinkProperties { /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */ private final Metering metering = new Metering(); + /** + * Quarkus has no {@code @ConfigurationProperties} binder, so the prefixed keys are read from + * MicroProfile config here; an unset key keeps the Java default above. + */ + @PostConstruct + void bindFromConfig() { + Config config = ConfigProvider.getConfig(); + read(config, "enabled", Boolean.class).ifPresent(this::setEnabled); + read(config, "saasBaseUrl", String.class).ifPresent(this::setSaasBaseUrl); + read(config, "entitlementCacheSeconds", Long.class) + .ifPresent(this::setEntitlementCacheSeconds); + read(config, "requestTimeoutSeconds", Integer.class) + .ifPresent(this::setRequestTimeoutSeconds); + read(config, "metering.enabled", Boolean.class).ifPresent(metering::setEnabled); + read(config, "metering.syncIntervalHours", Integer.class) + .ifPresent(metering::setSyncIntervalHours); + read(config, "metering.graceDays", Integer.class).ifPresent(metering::setGraceDays); + read(config, "metering.workflowWindow", Duration.class) + .ifPresent(metering::setWorkflowWindow); + } + + /** Spring's relaxed binding accepted either spelling of a key, so both are tried. */ + private static Optional read(Config config, String name, Class type) { + Optional value = config.getOptionalValue(PREFIX + toKebabCase(name), type); + return value.isPresent() ? value : config.getOptionalValue(PREFIX + name, type); + } + + private static String toKebabCase(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase(Locale.ROOT); + } + /** * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java index a9b11764b3..ee2fecd05b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java @@ -3,8 +3,6 @@ package stirling.software.proprietary.accountlink; import java.io.IOException; import java.util.Optional; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; @@ -18,10 +16,11 @@ import lombok.extern.slf4j.Slf4j; * JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest. * The credential — not the JWT — authenticates all later unattended entitlement calls. */ +// Arc cannot gate a bean on a runtime property, so the account-link flag no longer removes this +// bean; only the flag-gated link endpoints reach it, so nothing links while the flag is off. @Slf4j @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class AccountLinkService { private final AccountLinkClient client; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java index 15b5e3842d..9cfd0f4512 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java @@ -1,6 +1,25 @@ package stirling.software.proprietary.accountlink; -import org.springframework.data.jpa.repository.JpaRepository; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; /** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */ -public interface AccountLinkSyncStateRepository extends JpaRepository {} +@ApplicationScoped +public class AccountLinkSyncStateRepository + implements PanacheRepositoryBase { + + /** + * Spring Data's {@code save}. The id is assigned rather than generated, so a detached row must + * merge (insert-or-update); plain {@code persist} would reject the singleton on re-save. + */ + @Transactional + public AccountLinkSyncState save(AccountLinkSyncState state) { + if (state.getId() == null || getEntityManager().contains(state)) { + persist(state); + return state; + } + return getEntityManager().merge(state); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java index 3dd664c778..75230e30dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java @@ -1,38 +1,45 @@ package stirling.software.proprietary.accountlink; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import io.quarkus.arc.profile.IfBuildProfile; - -import jakarta.enterprise.context.ApplicationScoped; - /** - * Registers the account-link entitlement gate. Path patterns cover the billable API surface; the - * interceptor itself re-checks billability (and short-circuits manual tools), but scoping here - * keeps the gate off the bulk of interactive endpoints entirely. + * Path scope of the account-link entitlement gate. Spring bound {@link + * InstanceEntitlementInterceptor} to these patterns through an {@code InterceptorRegistry}; the + * filter that replaces it is mapped to {@code /*} and handed every request, so it asks here + * instead. Scoping still keeps the gate off the bulk of interactive endpoints; the interceptor + * itself re-checks billability (and short-circuits manual tools). * - *

Whole config is gated behind {@code stirling.billing.account-link.enabled} + - * {@code @IfBuildProfile("!saas")}; absent when off, so no interceptor is registered. + *

The flag {@code stirling.billing.account-link.enabled} is read by the filter and by {@code + * InstanceEntitlementGate} (allowing with {@code FLAG_OFF}), and {@code @IfBuildProfile("!saas")} + * stays on the interceptor, so "off" is as inert as the absent config bean was. */ -@ApplicationScoped -@IfBuildProfile("!saas") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") -public class AccountLinkWebMvcConfig implements WebMvcConfigurer { +public final class AccountLinkWebMvcConfig { - private final InstanceEntitlementInterceptor gateInterceptor; + // AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual calls + // be gated too, while the interceptor lets genuine manual tools through. + private static final String API_BASE = "/api/v1"; - public AccountLinkWebMvcConfig(InstanceEntitlementInterceptor gateInterceptor) { - this.gateInterceptor = gateInterceptor; + // Linking must stay reachable on an unlinked instance, or an API-key admin could never link it. + private static final String ACCOUNT_LINK_BASE = "/api/v1/account-link"; + + private AccountLinkWebMvcConfig() {} + + /** The mapped surface: {@code /api/v1/**}, excluding {@code /api/v1/account-link/**}. */ + public static boolean isGated(String path) { + // A servlet path always has one leading slash, UriInfo.getPath() may not; normalise both. + String uri = "/" + (path == null ? "" : path).replaceAll("^/+", ""); + return underBase(uri, API_BASE) && !underBase(uri, ACCOUNT_LINK_BASE); } - @Override - public void addInterceptors(InterceptorRegistry registry) { - // AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual - // calls be gated too, while the interceptor lets genuine manual tools through. - registry.addInterceptor(gateInterceptor) - .addPathPatterns("/api/v1/**") - .excludePathPatterns("/api/v1/account-link/**"); + /** + * Ant {@code base/**} semantics - the base itself or anything below it - segment-anchored so a + * sibling like {@code /api/v1x} never matches, and tolerant of a deployment context path prefix + * as {@code PolicyRunRoutes} is. + */ + private static boolean underBase(String uri, String base) { + int at = uri.indexOf(base); + if (at < 0) { + return false; + } + int end = at + base.length(); + return end == uri.length() || uri.charAt(end) == '/'; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java index 6ad1036cd6..f8fe452431 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java @@ -2,15 +2,29 @@ package stirling.software.proprietary.accountlink; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; @ApplicationScoped -public interface DeviceCredentialRepository extends JpaRepository { +public class DeviceCredentialRepository implements PanacheRepositoryBase { /** The singleton credential, if this instance has linked. */ - default Optional findCredential() { - return findById(DeviceCredential.SINGLETON_ID); + public Optional findCredential() { + return findByIdOptional(DeviceCredential.SINGLETON_ID); + } + + /** + * Spring Data's {@code save}. The id is assigned rather than generated, so a detached + * credential must merge (insert-or-update); re-linking replaces the existing row. + */ + @Transactional + public DeviceCredential save(DeviceCredential credential) { + if (credential.getId() == null || getEntityManager().contains(credential)) { + persist(credential); + return credential; + } + return getEntityManager().merge(credential); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java index 49aa0b1d0d..7567a32a54 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java @@ -3,8 +3,6 @@ package stirling.software.proprietary.accountlink; import java.time.LocalDateTime; import java.util.Optional; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; @@ -14,12 +12,12 @@ import jakarta.transaction.Transactional; * Secure-at-rest persistence for this instance's device credential. Thin wrapper over the * singleton-row repository so the rest of the feature never touches JPA directly. * - *

Gated + {@code @IfBuildProfile("!saas")}: only the self-hosted profile links outward to a SaaS - * team. + *

{@code @IfBuildProfile("!saas")}: only the self-hosted profile links outward to a SaaS team. */ +// Arc cannot gate a bean on a runtime property, so the account-link flag no longer removes this +// bean; every caller is flag-gated, and an unlinked instance simply has no credential row. @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class DeviceCredentialStore { private final DeviceCredentialRepository repo; @@ -28,12 +26,12 @@ public class DeviceCredentialStore { this.repo = repo; } - @Transactional(readOnly = true) + @Transactional public Optional get() { return repo.findCredential(); } - @Transactional(readOnly = true) + @Transactional public boolean isLinked() { return repo.findCredential().isPresent(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java index ef8dda2991..0779d1b173 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java @@ -5,8 +5,6 @@ import java.time.Instant; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; @@ -22,10 +20,11 @@ import lombok.extern.slf4j.Slf4j; * authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is * replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately. */ +// Arc cannot gate a bean on a runtime property, so the account-link flag no longer removes this +// bean; it stays inert until something asks, and an unlinked instance yields an empty snapshot. @Slf4j @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class EntitlementCache { private final DeviceCredentialStore credentialStore; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java index 74f4b8a02a..9ffb3ac11a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -3,8 +3,6 @@ package stirling.software.proprietary.accountlink; import java.time.LocalDateTime; import java.util.Optional; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; @@ -26,13 +24,14 @@ import jakarta.enterprise.context.ApplicationScoped; *

  • Billable + linked + over limit → block with {@code OVER_LIMIT}. * * - *

    The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live - * flag / linked-state / entitlement and computes whether the grace window has expired. This is the + *

    The decision logic is the pure static {@link #decide}; the bean wrapper supplies the live flag + * / linked-state / entitlement and computes whether the grace window has expired. This is the * unit-tested core. */ +// Arc cannot gate a bean on a runtime property, so the account-link flag no longer removes this +// bean; evaluate() reads the flag itself and allows with FLAG_OFF, as bean-absence used to. @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class InstanceEntitlementGate { private final AccountLinkProperties properties; @@ -82,7 +81,7 @@ public class InstanceEntitlementGate { } /** - * Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown" + * Pure decision function — no framework, no I/O. {@code entitlement} empty means "unknown" * (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace * window elapsed with no authoritative contact), in which case it blocks. * @@ -145,7 +144,7 @@ public class InstanceEntitlementGate { private LocalDateTime lastAuthoritativeContact() { LocalDateTime lastSuccess = syncStateRepository - .findById(AccountLinkSyncState.SINGLETON_ID) + .findByIdOptional(AccountLinkSyncState.SINGLETON_ID) .map(AccountLinkSyncState::getLastSuccessAt) .orElse(null); if (lastSuccess != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 824dfb612a..cb83fc20e2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -8,25 +8,28 @@ import java.nio.file.Path; import java.security.DigestOutputStream; import java.security.MessageDigest; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpStatus; -import org.springframework.web.multipart.MultipartHttpServletRequest; -import org.springframework.web.servlet.HandlerInterceptor; -import org.springframework.web.util.WebUtils; - import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Part; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.model.MultipartFile; import stirling.software.common.security.SecurityContextHolder; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; @@ -46,38 +49,61 @@ import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; * *

    Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate" * prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind - * {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link - * UsageMeterService} bean is absent and nothing accrues, while the gate still works. + * {@code …metering.enabled}, tested before every accrual — switch off means nothing accrues, while + * the gate still works. */ +// Servlet filter retained (quarkus-undertow): only the servlet API exposes the parsed multipart +// parts, the attribute preHandle/afterCompletion pass the category through, and the raw status. +// Arc cannot gate a bean on a runtime property, so the account-link flag no longer removes this +// bean; doFilter reads the flag itself and passes straight through, as bean-absence used to. @Slf4j @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") -public class InstanceEntitlementInterceptor implements HandlerInterceptor { +@WebFilter("/*") +public class InstanceEntitlementInterceptor implements Filter { private static final String ATTR_CATEGORY = InstanceEntitlementInterceptor.class.getName() + ".category"; - private final InstanceEntitlementGate gate; - private final EntitlementCache entitlementCache; - private final ObjectProvider meterProvider; - private final TempFileManager tempFileManager; - - public InstanceEntitlementInterceptor( - InstanceEntitlementGate gate, - EntitlementCache entitlementCache, - ObjectProvider meterProvider, - TempFileManager tempFileManager) { - this.gate = gate; - this.entitlementCache = entitlementCache; - this.meterProvider = meterProvider; - this.tempFileManager = tempFileManager; - } + // Field injection, not constructor: Undertow instantiates a @WebFilter through the servlet + // container's instance factory, which needs a no-arg constructor. + @Inject InstanceEntitlementGate gate; + @Inject EntitlementCache entitlementCache; + @Inject AccountLinkProperties properties; + @Inject Instance meterProvider; + @Inject TempFileManager tempFileManager; @Override - public boolean preHandle( - HttpServletRequest request, HttpServletResponse response, Object handler) - throws Exception { + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + + // Self-gate on the path scope the InterceptorRegistry used to apply (see + // AccountLinkWebMvcConfig): a filter mapped to /* is handed every request. + if (!properties.isEnabled() || !AccountLinkWebMvcConfig.isGated(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + if (!preHandle(request, response)) { + return; + } + // A throwing chain is the `ex` Spring handed afterCompletion - same "don't meter" signal. + Exception failure = null; + try { + filterChain.doFilter(request, response); + } catch (IOException | ServletException | RuntimeException e) { + failure = e; + throw e; + } finally { + afterCompletion(request, response, failure); + } + } + + // Package-private, not private: the two phases stay individually drivable from the unit test, + // the way HandlerInterceptor's were. + boolean preHandle(HttpServletRequest request, HttpServletResponse response) throws IOException { GateDecision decision; try { // API-key tool calls are billable (category API); stash the category for the meter. @@ -104,7 +130,7 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { } log.debug("Account-link gate blocked {} ({})", request.getRequestURI(), decision.reason()); - response.setStatus(HttpStatus.PAYMENT_REQUIRED.value()); + response.setStatus(Response.Status.PAYMENT_REQUIRED.getStatusCode()); response.setContentType("application/json"); response.getWriter() .write( @@ -114,20 +140,15 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { return false; } - @Override - public void afterCompletion( - HttpServletRequest request, - HttpServletResponse response, - Object handler, - Exception ex) { + void afterCompletion(HttpServletRequest request, HttpServletResponse response, Exception ex) { // Meter successful billable ops only. if (ex != null || response.getStatus() >= 400) { return; } - UsageMeterService meter = meterProvider.getIfAvailable(); - if (meter == null) { + if (!properties.getMetering().isEnabled() || !meterProvider.isResolvable()) { return; // metering switch off } + UsageMeterService meter = meterProvider.get(); if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category) || category == BillingCategory.BYPASSED) { return; @@ -157,9 +178,8 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { InstanceEntitlement ent, UsageMeterService meter) { UnitCalcPolicy policy = ent.unitCalcPolicy(); - MultipartHttpServletRequest mreq = - WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); - if (mreq == null) { + List fileParts = fileParts(request); + if (fileParts == null) { long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy); meter.accrue(ent.periodStart(), category, fileless, null); return; @@ -169,32 +189,30 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { List sizes = new ArrayList<>(); List hashes = new ArrayList<>(); int fileCount = 0; - for (List files : mreq.getMultiFileMap().values()) { - for (MultipartFile f : files) { - fileCount++; - try { - TempFile temp = tempFileManager.createManagedTempFile(".bin"); - temps.add(temp); - // Hash in the same pass that writes the temp file — one read of the upload, - // not a second full read just to fingerprint it. - MessageDigest digest = ContentHasher.newSha256(); - try (InputStream in = f.getInputStream(); - DigestOutputStream out = - new DigestOutputStream( - Files.newOutputStream(temp.getPath()), digest)) { - in.transferTo(out); - } - sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize())); - hashes.add(ContentHasher.toHex(digest.digest())); - } catch (IOException | RuntimeException perFile) { - // Couldn't materialise/hash this input — bill on bytes only and, by leaving - // it out of `hashes`, drop dedup for the whole op rather than risk a - // mismatch. - log.debug( - "Metering materialise/hash failed for {}; bytes-only", - f.getOriginalFilename()); - sizes.add(new FileSize(0, f.getSize())); + for (Part f : fileParts) { + fileCount++; + try { + TempFile temp = tempFileManager.createManagedTempFile(".bin"); + temps.add(temp); + // Hash in the same pass that writes the temp file — one read of the upload, + // not a second full read just to fingerprint it. + MessageDigest digest = ContentHasher.newSha256(); + try (InputStream in = f.getInputStream(); + DigestOutputStream out = + new DigestOutputStream( + Files.newOutputStream(temp.getPath()), digest)) { + in.transferTo(out); } + sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize())); + hashes.add(ContentHasher.toHex(digest.digest())); + } catch (IOException | RuntimeException perFile) { + // Couldn't materialise/hash this input — bill on bytes only and, by leaving + // it out of `hashes`, drop dedup for the whole op rather than risk a + // mismatch. + log.debug( + "Metering materialise/hash failed for {}; bytes-only", + f.getSubmittedFileName()); + sizes.add(new FileSize(0, f.getSize())); } } long units = @@ -217,8 +235,35 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { } } + /** + * The uploaded inputs - parts carrying a filename, exactly what Spring's multi-file map held - + * or null when the request is not multipart at all (the fileless op main spotted by the absence + * of a native multipart request). Parts that can no longer be read yield none, billing the same + * 1-unit floor as a fileless op rather than inventing inputs. + */ + private static List fileParts(HttpServletRequest request) { + String contentType = request.getContentType(); + if (contentType == null || !contentType.toLowerCase().startsWith("multipart/form-data")) { + return null; + } + List files = new ArrayList<>(); + try { + Collection parts = request.getParts(); + if (parts != null) { + for (Part part : parts) { + if (part.getSubmittedFileName() != null) { + files.add(part); + } + } + } + } catch (IOException | ServletException | RuntimeException e) { + log.debug("Metering could not read the multipart parts of {}", request.getRequestURI()); + } + return files; + } + /** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */ - private static int pageCount(Path path, MultipartFile file) { + private static int pageCount(Path path, Part file) { if (!isPdf(file)) { return 0; } @@ -228,7 +273,7 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { // Malformed / encrypted → byte axis only, matching the SaaS classifier. log.debug( "Page count unavailable for {}; metering on bytes only", - file.getOriginalFilename()); + file.getSubmittedFileName()); return 0; } } @@ -240,12 +285,12 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8)); } - private static boolean isPdf(MultipartFile file) { + private static boolean isPdf(Part file) { String contentType = file.getContentType(); if (contentType != null && contentType.toLowerCase().contains("pdf")) { return true; } - String name = file.getOriginalFilename(); + String name = file.getSubmittedFileName(); return name != null && name.toLowerCase().endsWith(".pdf"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java index f74759c8c2..2bc50cc739 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java @@ -3,8 +3,6 @@ package stirling.software.proprietary.accountlink; import java.time.LocalDateTime; import java.util.EnumMap; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; @@ -19,9 +17,10 @@ import stirling.software.proprietary.billing.BillingCategory; * the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown * or metering is off. */ +// Arc cannot gate a bean on a runtime property, so the account-link flag no longer removes this +// bean; it reports zeros unless the cache holds a period, which only a linked instance has. @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class LocalUsageService { private final UsageCounterRepository counters; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java index 863f503f61..d5b75114ea 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java @@ -3,13 +3,40 @@ package stirling.software.proprietary.accountlink; import java.time.LocalDateTime; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; /** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */ -public interface MeteredInputSignatureRepository - extends JpaRepository { +@ApplicationScoped +public class MeteredInputSignatureRepository + implements PanacheRepositoryBase { /** The existing row for a seen input set, so the meter can apply the workflow-window check. */ - Optional findByPeriodStartAndSignature( - LocalDateTime periodStart, String signature); + public Optional findByPeriodStartAndSignature( + LocalDateTime periodStart, String signature) { + return find("periodStart = ?1 and signature = ?2", periodStart, signature) + .firstResultOptional(); + } + + /** Spring Data's {@code save}: the generated id decides insert, a detached row merges. */ + @Transactional + public MeteredInputSignature save(MeteredInputSignature signature) { + if (signature.getId() == null || getEntityManager().contains(signature)) { + persist(signature); + return signature; + } + return getEntityManager().merge(signature); + } + + /** + * Spring Data's {@code saveAndFlush}: flushes here so a lost first-sighting claim surfaces as a + * unique-constraint {@code PersistenceException} the meter can treat as chaining. + */ + @Transactional + public MeteredInputSignature saveAndFlush(MeteredInputSignature signature) { + persistAndFlush(signature); + return signature; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java index fb4cd0f170..adda847362 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -3,56 +3,72 @@ package stirling.software.proprietary.accountlink; import java.time.LocalDateTime; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; /** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */ -public interface UsageCounterRepository extends JpaRepository { +@ApplicationScoped +public class UsageCounterRepository implements PanacheRepositoryBase { /** * Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated * (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a * read-modify-write race between concurrent billable requests. */ - @Modifying @Transactional - @Query( - "UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta," - + " c.updatedAt = :now" - + " WHERE c.periodStart = :periodStart AND c.category = :category") - int increment( - @Param("periodStart") LocalDateTime periodStart, - @Param("category") String category, - @Param("delta") long delta, - @Param("now") LocalDateTime now); + public int increment( + LocalDateTime periodStart, String category, long delta, LocalDateTime now) { + return update( + "UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta," + + " c.updatedAt = :now" + + " WHERE c.periodStart = :periodStart AND c.category = :category", + Parameters.with("delta", delta) + .and("now", now) + .and("periodStart", periodStart) + .and("category", category)); + } /** All counters for a period — the daily sync reads these to report cumulative totals. */ - List findByPeriodStart(LocalDateTime periodStart); + public List findByPeriodStart(LocalDateTime periodStart) { + return list("periodStart", periodStart); + } /** * Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each * so end-of-period usage isn't stranded when the billing period rolls over between syncs. */ - @Query( - "SELECT DISTINCT c.periodStart FROM UsageCounter c" - + " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart") - List findPeriodsWithUnsyncedUsage(); + public List findPeriodsWithUnsyncedUsage() { + // Projection of a single column, so it goes through the EntityManager rather than Panache. + return getEntityManager() + .createQuery( + "SELECT DISTINCT c.periodStart FROM UsageCounter c" + + " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY" + + " c.periodStart", + LocalDateTime.class) + .getResultList(); + } /** * Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by * SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced. */ - @Modifying @Transactional - @Query( - "UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits" - + " WHERE c.periodStart = :periodStart AND c.category = :category") - int markSynced( - @Param("periodStart") LocalDateTime periodStart, - @Param("category") String category, - @Param("syncedUnits") long syncedUnits); + public int markSynced(LocalDateTime periodStart, String category, long syncedUnits) { + return update( + "UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits" + + " WHERE c.periodStart = :periodStart AND c.category = :category", + Parameters.with("syncedUnits", syncedUnits) + .and("periodStart", periodStart) + .and("category", category)); + } + + /** Spring Data's {@code saveAndFlush}: flushes so the unique-constraint race surfaces here. */ + @Transactional + public UsageCounter saveAndFlush(UsageCounter counter) { + persistAndFlush(counter); + return counter; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java index 4250d27fe3..76d35fc3e8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java @@ -3,8 +3,6 @@ package stirling.software.proprietary.accountlink; import java.time.Duration; import java.time.LocalDateTime; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; @@ -24,12 +22,11 @@ import stirling.software.proprietary.billing.BillingCategory; * instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue} * is best-effort: callers need not handle persistence errors. */ +// Arc cannot gate a bean on a runtime property, so the metering flag no longer removes this bean; +// callers check AccountLinkProperties.getMetering().isEnabled() before accruing. @Slf4j @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty( - name = "stirling.billing.account-link.metering.enabled", - havingValue = "true") public class UsageMeterService { private final UsageCounterRepository repo; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java index aaaad648f5..1074bee5c3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -6,14 +6,14 @@ import java.util.EnumMap; import java.util.List; import java.util.Optional; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.SchedulingConfigurer; -import org.springframework.scheduling.config.FixedDelayTask; -import org.springframework.scheduling.config.ScheduledTaskRegistrar; - import io.quarkus.arc.profile.IfBuildProfile; +import io.quarkus.runtime.StartupEvent; +import io.quarkus.scheduler.Scheduled; +import io.quarkus.scheduler.Scheduler; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; @@ -29,13 +29,14 @@ import stirling.software.proprietary.billing.BillingCategory; * periods with unsynced usage are reported so nothing is stranded when the period rolls over * between syncs. */ +// Arc cannot gate a bean on a runtime property, so the metering flag no longer removes this bean; +// the startup hook schedules nothing unless the flags are on, as bean-absence used to. @Slf4j @ApplicationScoped @IfBuildProfile("!saas") -@ConditionalOnProperty( - name = "stirling.billing.account-link.metering.enabled", - havingValue = "true") -public class UsageSyncService implements SchedulingConfigurer { +public class UsageSyncService { + + static final String SYNC_JOB = "account-link-usage-sync"; // First run waits out startup churn; then every interval. private static final Duration INITIAL_DELAY = Duration.ofMinutes(5); @@ -47,6 +48,9 @@ public class UsageSyncService implements SchedulingConfigurer { private final EntitlementCache entitlementCache; private final AccountLinkProperties properties; + // Field-injected so the constructor stays the one callers and tests already build with. + @Inject Scheduler scheduler; + public UsageSyncService( UsageCounterRepository counters, AccountLinkSyncStateRepository syncState, @@ -64,14 +68,24 @@ public class UsageSyncService implements SchedulingConfigurer { /** * Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in - * code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather - * than only on a flags-on run. + * code rather than a {@code @Scheduled} config expression so a bad interval fails at boot/test + * rather than only on a flags-on run. */ - @Override - public void configureTasks(ScheduledTaskRegistrar registrar) { - Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours()); - registrar.addFixedDelayTask( - new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY)); + void registerSyncJob(@Observes StartupEvent event) { + AccountLinkProperties.Metering metering = properties.getMetering(); + // Metering needs the master flag too, so either flag off leaves the instance unscheduled. + if (!properties.isEnabled() || !metering.isEnabled()) { + return; + } + Duration interval = Duration.ofHours(metering.getSyncIntervalHours()); + scheduler + .newJob(SYNC_JOB) + .setInterval(interval.toString()) + .setDelayed(INITIAL_DELAY.toString()) + // SKIP keeps the job non-reentrant, as the Spring fixed-delay task was. + .setConcurrentExecution(Scheduled.ConcurrentExecution.SKIP) + .setTask(execution -> scheduledSync()) + .schedule(); } public void scheduledSync() { @@ -84,8 +98,8 @@ public class UsageSyncService implements SchedulingConfigurer { /** * Reports every period with unsynced usage and refreshes the cached entitlement from the reply. - * Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when - * unlinked or when nothing is pending. + * Single daily caller (the job is non-reentrant), so no internal locking. No-op when unlinked + * or when nothing is pending. */ public void syncNow() { Optional cred = credentialStore.get(); @@ -180,7 +194,7 @@ public class UsageSyncService implements SchedulingConfigurer { private AccountLinkSyncState loadState() { return syncState - .findById(AccountLinkSyncState.SINGLETON_ID) + .findByIdOptional(AccountLinkSyncState.SINGLETON_ID) .orElseGet( () -> { AccountLinkSyncState s = new AccountLinkSyncState(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java index 04e6f9892b..ce1f1e9de3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java @@ -9,24 +9,26 @@ import java.util.Map; import java.util.Set; import org.apache.pdfbox.pdmodel.PDDocument; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.MultipartFile; -import stirling.software.common.model.io.Resource; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.UserServiceInterface; @@ -54,8 +56,8 @@ import tools.jackson.databind.node.ObjectNode; */ @Slf4j @Hidden -@RestController -@RequestMapping("/api/v1/ai/tools") +@ApplicationScoped +@Path("/api/v1/ai/tools") @Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") public class ClassifyLabelController { @@ -99,7 +101,9 @@ public class ClassifyLabelController { this.userService = userService.isResolvable() ? userService.get() : null; } - @PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @Path("/classify-and-label") + @Consumes(MediaType.MULTIPART_FORM_DATA) @Operation( summary = "Classify a PDF and label its metadata", description = @@ -107,9 +111,10 @@ public class ClassifyLabelController { + " engine, and stores the result in the StirlingPDFClassification" + " metadata field. Dispatched by the Classification policy; not" + " intended for direct client use.") - public ResponseEntity classifyAndLabel( - @RequestParam("fileInput") MultipartFile fileInput) throws IOException { + public Response classifyAndLabel(@RestForm("fileInput") FileUpload fileInputUpload) + throws IOException { aiFeatureGate.requireClassify(); + MultipartFile fileInput = FileUploadMultipartFile.of(fileInputUpload); try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { String fileName = safeFileName(fileInput.getOriginalFilename()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java index 7c5172940b..e76980c57c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java @@ -4,13 +4,14 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import io.quarkus.arc.profile.IfBuildProfile; +import io.quarkus.arc.profile.UnlessBuildProfile; import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,22 +37,24 @@ import stirling.software.proprietary.security.database.repository.UserRepository * carry {@code source=null}, so the cumulative "PDFs edited" figure effectively starts at deploy. */ @Slf4j -@RestController -@RequestMapping("/api/v1/usage") +@ApplicationScoped +@Path("/api/v1/usage") @RolesAllowed("ADMIN") @RequiredArgsConstructor @EnterpriseEndpoint // Self-hosted only: counts are server-wide. On SaaS this endpoint is owned by the team-scoped // SaasFleetUsageController (@IfBuildProfile("saas")) so one backend can't leak another tenant's // usage. -@IfBuildProfile("!saas") +@UnlessBuildProfile("saas") public class FleetUsageController { private final PersistentAuditEventRepository auditRepository; private final UserRepository userRepository; private final AuditConfigurationProperties auditConfig; - @GetMapping("/fleet-stats") + @GET + @Path("/fleet-stats") + @Produces(MediaType.APPLICATION_JSON) public FleetUsageStats fleetStats() { // Exclude the reserved INTERNAL_API_USER row that InitialSecuritySetup creates on every // install, so a fresh single-admin instance reads 1 editor, not 2. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java index af0f4d055a..9f0d8fcda6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java @@ -1,21 +1,23 @@ package stirling.software.proprietary.controller.api; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; - import io.swagger.v3.oas.annotations.Operation; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + import lombok.RequiredArgsConstructor; import stirling.software.common.annotations.api.ProprietaryUiDataApi; import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest; -import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto; -import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse; import stirling.software.proprietary.security.service.ApiKeyManagementService; /** @@ -23,32 +25,39 @@ import stirling.software.proprietary.security.service.ApiKeyManagementService; * keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API * keys are a core auth feature available on every self-hosted instance. */ +@ApplicationScoped @ProprietaryUiDataApi +@Path("/api/v1/proprietary/ui-data") @RequiredArgsConstructor public class PortalApiKeysController { private final ApiKeyManagementService apiKeyManagementService; // tier accepted for endpoint symmetry with the other infra tabs; ignored here. - @GetMapping("/infrastructure/api-keys") + @GET + @Path("/infrastructure/api-keys") + @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "List API keys", description = "The caller's personal API keys.") - public ResponseEntity list( - @RequestParam(value = "tier", required = false) String tier) { - return ResponseEntity.ok(apiKeyManagementService.listVisibleKeys()); + public Response list(@QueryParam("tier") String tier) { + return Response.ok(apiKeyManagementService.listVisibleKeys()).build(); } - @PostMapping("/infrastructure/api-keys") + @POST + @Path("/infrastructure/api-keys") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Create an API key", description = "Mints a personal key and returns its one-time secret.") - public ResponseEntity create(@RequestBody CreateApiKeyRequest request) { - return ResponseEntity.ok(apiKeyManagementService.createKey(request)); + public Response create(CreateApiKeyRequest request) { + return Response.ok(apiKeyManagementService.createKey(request)).build(); } - @DeleteMapping("/infrastructure/api-keys/{id}") + @DELETE + @Path("/infrastructure/api-keys/{id}") @Operation(summary = "Revoke an API key", description = "Disables a key the caller owns.") - public ResponseEntity revoke(@PathVariable("id") Long id) { + public Response revoke(@PathParam("id") Long id) { apiKeyManagementService.revokeKey(id); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java index 14146eacb4..53c8934877 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java @@ -1,12 +1,15 @@ package stirling.software.proprietary.controller.api; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - import io.swagger.v3.oas.annotations.Operation; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + import lombok.RequiredArgsConstructor; import stirling.software.common.annotations.api.ProprietaryUiDataApi; @@ -17,7 +20,11 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.service.PortalDocumentsService; /** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */ +@ApplicationScoped @ProprietaryUiDataApi +// @ProprietaryUiDataApi carries only the OpenAPI @Tag; JAX-RS does not inherit @Path from +// meta-annotations, so the base path is declared explicitly here. +@Path("/api/v1/proprietary/ui-data") @RequiredArgsConstructor @EnterpriseEndpoint public class PortalDocumentsController { @@ -26,21 +33,22 @@ public class PortalDocumentsController { private final PortalAuditScopeResolver auditScopeResolver; // tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped). - @GetMapping("/documents") + @GET + @Path("/documents") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Documents review queue", description = "Files processed through the org, derived from the audit trail.") - public ResponseEntity getDocuments( - @RequestParam(value = "tier", required = false) String tier) { + public Response getDocuments(@QueryParam("tier") String tier) { PortalAuditScope scope = auditScopeResolver.resolve(); if (!scope.allowed()) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return Response.status(Response.Status.FORBIDDEN).build(); } PortalDocumentsResponseDto body = scope.fullServer() ? portalDocumentsService.serverDocuments() : portalDocumentsService.scopedDocuments( scope.cacheKey(), scope.principals()); - return ResponseEntity.ok(body); + return Response.ok(body).build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java index 866c295b8d..b8c00f5768 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java @@ -1,12 +1,15 @@ package stirling.software.proprietary.controller.api; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - import io.swagger.v3.oas.annotations.Operation; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + import lombok.RequiredArgsConstructor; import stirling.software.common.annotations.api.ProprietaryUiDataApi; @@ -17,7 +20,11 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.service.PortalInfraAuditService; /** Serves the Infrastructure → Audit tab from real audit data, scoped and cached per caller. */ +@ApplicationScoped @ProprietaryUiDataApi +// @ProprietaryUiDataApi carries only the OpenAPI @Tag; JAX-RS does not inherit @Path from +// meta-annotations, so the base path is declared explicitly here. +@Path("/api/v1/proprietary/ui-data") @RequiredArgsConstructor @EnterpriseEndpoint public class PortalInfraAuditController { @@ -26,22 +33,23 @@ public class PortalInfraAuditController { private final PortalAuditScopeResolver auditScopeResolver; // tier accepted for endpoint symmetry; ignored (audit log isn't tier-scoped). - @GetMapping("/infrastructure/audit-log") + @GET + @Path("/infrastructure/audit-log") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Infrastructure audit log", description = "Recent audit events shaped for the portal Infrastructure → Audit tab.") - public ResponseEntity getInfrastructureAuditLog( - @RequestParam(value = "tier", required = false) String tier) { + public Response getInfrastructureAuditLog(@QueryParam("tier") String tier) { PortalAuditScope scope = auditScopeResolver.resolve(); if (!scope.allowed()) { // Return 403 (not throw) so the tab shows its access message, not a generic 500. - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return Response.status(Response.Status.FORBIDDEN).build(); } InfraAuditLogResponse body = scope.fullServer() ? portalInfraAuditService.serverAuditLog() : portalInfraAuditService.scopedAuditLog( scope.cacheKey(), scope.principals()); - return ResponseEntity.ok(body); + return Response.ok(body).build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java index 7b04a3dfc7..59a64c808e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java @@ -33,7 +33,8 @@ import tools.jackson.databind.ObjectMapper; */ @ApplicationScoped @RequiredArgsConstructor -@Transactional(readOnly = true) +// jakarta.transaction.Transactional has no readOnly hint; the reads are unchanged without it. +@Transactional public class ApiConnectionResolver { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -46,7 +47,7 @@ public class ApiConnectionResolver { public Map resolveConfig(Long connectionId, IntegrationType type) { IntegrationConfig connection = connections - .findById(connectionId) + .findByIdOptional(connectionId) .filter(cfg -> cfg.getIntegrationType() == type) .filter(this::usableByCurrentUser) // Existence and access collapse into one error so a caller cannot tell diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java index 3610f77ab0..e0bbc2c1fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java @@ -8,11 +8,11 @@ import java.time.Duration; import java.util.Map; import java.util.concurrent.TimeUnit; -import org.springframework.http.MediaType; - import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import jakarta.ws.rs.core.MediaType; + import lombok.extern.slf4j.Slf4j; import tools.jackson.databind.ObjectMapper; @@ -115,7 +115,7 @@ public class ApiTokenCache { HttpRequest.Builder request = HttpRequest.newBuilder(target) .timeout(Duration.ofSeconds(settings.timeoutSeconds())) - .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .header("Content-Type", MediaType.APPLICATION_JSON) .POST( HttpRequest.BodyPublishers.ofByteArray( objectMapper.writeValueAsBytes(login.loginBody()))); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java index 8598743bc6..cec2178245 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java @@ -1,32 +1,38 @@ package stirling.software.proprietary.integration.api; import java.io.IOException; +import java.io.InputStream; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.MultipartFile; import stirling.software.common.model.io.Resource; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.AutomationRunContext; @@ -62,8 +68,8 @@ import tools.jackson.databind.node.ObjectNode; * */ @Slf4j -@RestController -@RequestMapping("/api/v1/integration") +@ApplicationScoped +@Path("/api/v1/integration") @RequiredArgsConstructor @Tag(name = "Integrations", description = "Third-party integration steps.") public class ExternalApiCallController { @@ -93,7 +99,9 @@ public class ExternalApiCallController { // The document is forwarded as bytes and never parsed, and in 'replace' mode the response // becomes the document, so neither side can be pinned to a format. - @PostMapping(value = "/external-api-call", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @Path("/external-api-call") + @Consumes(MediaType.MULTIPART_FORM_DATA) @ToolIO(accepts = ToolFormat.ANY, produces = ToolFormat.ANY) @Operation( summary = "Send the document to an external API", @@ -103,27 +111,25 @@ public class ExternalApiCallController { + " document with it. Fields, path and headers may reference" + " {{document.*}}, {{classification.*}}, {{sensitivityLabel.*}} and" + " {{run.*}}.") - public ResponseEntity call( - @RequestParam("fileInput") MultipartFile fileInput, - @RequestParam("connectionId") String connectionId, - @RequestParam(value = "path", required = false) String path, - @RequestParam(value = "method", defaultValue = "POST") String method, - @RequestParam(value = "bodyMode", defaultValue = BODY_MULTIPART) String bodyMode, - @RequestParam(value = "fileFieldName", defaultValue = "file") String fileFieldName, - @RequestParam(value = "responseMode", defaultValue = MODE_REPORT) String responseMode, - @RequestParam(value = "resultUrlPath", required = false) String resultUrlPath, - @RequestParam(value = "resultUrlHeader", required = false) String resultUrlHeader, - @RequestParam(value = "responseSelect", required = false) String responseSelect, - @RequestParam(value = "requireTrue", required = false) String requireTrue, - @RequestParam(value = "fields", required = false) String fields, - @RequestParam(value = "bodyTemplate", required = false) String bodyTemplate, - @RequestParam(value = "headers", required = false) String headers, - @RequestParam(value = "includeContext", defaultValue = "false") boolean includeContext, - @RequestParam(value = "includeFile", defaultValue = "true") boolean includeFile, - @RequestHeader(value = InternalApiClient.POLICY_NAME_HEADER, required = false) - String policyName, - @RequestHeader(value = AutomationRunContext.RUN_ID_HEADER, required = false) - String runId) + public Response call( + @RestForm("fileInput") FileUpload fileInput, + @RestForm("connectionId") String connectionId, + @RestForm("path") String path, + @RestForm("method") @DefaultValue("POST") String method, + @RestForm("bodyMode") @DefaultValue(BODY_MULTIPART) String bodyMode, + @RestForm("fileFieldName") @DefaultValue("file") String fileFieldName, + @RestForm("responseMode") @DefaultValue(MODE_REPORT) String responseMode, + @RestForm("resultUrlPath") String resultUrlPath, + @RestForm("resultUrlHeader") String resultUrlHeader, + @RestForm("responseSelect") String responseSelect, + @RestForm("requireTrue") String requireTrue, + @RestForm("fields") String fields, + @RestForm("bodyTemplate") String bodyTemplate, + @RestForm("headers") String headers, + @RestForm("includeContext") @DefaultValue("false") boolean includeContext, + @RestForm("includeFile") @DefaultValue("true") boolean includeFile, + @HeaderParam(InternalApiClient.POLICY_NAME_HEADER) String policyName, + @HeaderParam(AutomationRunContext.RUN_ID_HEADER) String runId) throws IOException { String mode = normalise(responseMode, MODE_REPORT, MODE_REPORT, MODE_REPLACE); @@ -136,15 +142,15 @@ public class ExternalApiCallController { } ApiConnectionSettings settings = connectionResolver.resolve(id); - String filename = safeFileName(fileInput.getOriginalFilename()); + MultipartFile file = FileUploadMultipartFile.of(fileInput); + String filename = safeFileName(file.getOriginalFilename()); String contentType = - fileInput.getContentType() == null - ? MediaType.APPLICATION_OCTET_STREAM_VALUE - : fileInput.getContentType(); - byte[] content = fileInput.getBytes(); + file.getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM + : file.getContentType(); + byte[] content = file.getBytes(); - ObjectNode context = - DocumentContext.build(fileInput, content, policyName, runId, objectMapper); + ObjectNode context = DocumentContext.build(file, content, policyName, runId, objectMapper); ExternalApiCaller.Response response = caller.dispatch( @@ -181,7 +187,7 @@ public class ExternalApiCallController { resultUrlPath, resultUrlHeader, responseSelect) - : reportOnly(fileInput, filename, contentType, response); + : reportOnly(file, filename, contentType, response); } /** @@ -237,7 +243,7 @@ public class ExternalApiCallController { json.put("content", Base64.getEncoder().encodeToString(content)); } return ExternalApiCaller.raw( - MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(json)); + MediaType.APPLICATION_JSON, objectMapper.writeValueAsBytes(json)); } default -> { Map all = new LinkedHashMap<>(fields); @@ -287,7 +293,7 @@ public class ExternalApiCallController { } JsonNode resolved = Placeholders.resolveTree(template, withFile); return ExternalApiCaller.raw( - MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(resolved)); + MediaType.APPLICATION_JSON, objectMapper.writeValueAsBytes(resolved)); } /** Resolve every value's placeholders against the context. */ @@ -351,7 +357,7 @@ public class ExternalApiCallController { * a URL to fetch it from, or an archive to pick it out of. Anything else fails the step rather * than putting a non-document into the pipeline for a later step to trip over. */ - private ResponseEntity replaceDocument( + private Response replaceDocument( ApiConnectionSettings settings, ExternalApiCaller.Response response, String requestFilename, @@ -406,14 +412,27 @@ public class ExternalApiCallController { MediaType type = payload.contentType() == null || ResultFiles.isArchiveName(filename) - ? MediaType.APPLICATION_OCTET_STREAM - : MediaType.parseMediaType(payload.contentType().split(";")[0].trim()); - return ResponseEntity.ok() - .contentType(type) - .header( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + filename + "\"") - .body(result); + ? MediaType.APPLICATION_OCTET_STREAM_TYPE + : MediaType.valueOf(payload.contentType().split(";")[0].trim()); + Response.ResponseBuilder ok = + Response.ok(stream(result), type) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\""); + long length = result.contentLength(); + if (length >= 0) { + ok.header(HttpHeaders.CONTENT_LENGTH, length); + } + return ok.build(); + } + + /** Writes the chosen bytes out, as Spring's Resource converter did once the step returned. */ + private static StreamingOutput stream(Resource resource) { + return output -> { + try (InputStream in = resource.getInputStream()) { + in.transferTo(output); + } + }; } /** The result URL the API pointed at, from the body or a header; null when neither is set. */ @@ -476,19 +495,18 @@ public class ExternalApiCallController { } /** The document passes through; the API's answer rides in the report header. */ - private ResponseEntity reportOnly( + private Response reportOnly( MultipartFile fileInput, String filename, String contentType, ExternalApiCaller.Response response) throws IOException { - return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(contentType)) + return Response.ok(fileInput.getBytes(), MediaType.valueOf(contentType)) .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") .header(AiToolResponseHeaders.TOOL_REPORT, buildReport(response)) - .body(new ByteArrayResource(fileInput.getBytes())); + .build(); } /** A JSON object describing the call, small enough to survive as a header. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java index edd8f0d7ae..62367770e3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java @@ -1,13 +1,14 @@ package stirling.software.proprietary.integration.api; +import java.io.ByteArrayInputStream; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; -import org.springframework.core.io.ByteArrayResource; - import stirling.software.common.model.io.Resource; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.ZipExtractionUtils; @@ -132,12 +133,49 @@ final class ResultFiles { } static Resource asResource(byte[] content, String filename) { - return new ByteArrayResource(content) { - @Override - public String getFilename() { - return filename; - } - }; + return new ByteArrayBackedResource(content, filename); + } + + /** + * In-memory {@link Resource} replacing Spring's {@code ByteArrayResource}. The response bytes + * are read more than once - sniffed for the ZIP magic, then extracted or streamed out - and + * {@code ZipExtractionUtils} sizes a resource before reading it, so the common {@code + * InputStreamResource} shim (single-read, length {@code -1}) will not do here. + */ + private static final class ByteArrayBackedResource implements Resource { + + private final byte[] content; + private final String filename; + + ByteArrayBackedResource(byte[] content, String filename) { + this.content = content; + this.filename = filename; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public boolean exists() { + return true; + } + + @Override + public String getFilename() { + return filename; + } + + @Override + public long contentLength() { + return content.length; + } + + @Override + public File getFile() throws IOException { + throw new IOException("the API response is held in memory, not backed by a file"); + } } /** Only {@code *} is supported, and only against the entry's own name. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java index ff24e211ca..6bb30638a9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java @@ -1,54 +1,62 @@ package stirling.software.proprietary.integration.controller; -import java.util.List; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; +import java.security.Principal; +import io.quarkus.security.identity.SecurityIdentity; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + import lombok.RequiredArgsConstructor; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; -import stirling.software.proprietary.integration.dto.IntegrationConfigResponse; import stirling.software.proprietary.integration.service.IntegrationConfigService; import stirling.software.proprietary.security.model.User; /** CRUD for S3/MCP/API integration configs. Secrets are never returned. */ -@RestController -@RequestMapping("/api/v1/integrations") +@ApplicationScoped +@Path("/api/v1/integrations") +@Produces(MediaType.APPLICATION_JSON) @RequiredArgsConstructor // Portal-exclusive: server-side portal-access boundary, not just isAuthenticated. Per-config // ownership is still enforced in the service layer. -@PreAuthorize("@resourceAccess.canUsePortal()") @Tag(name = "Integrations", description = "Manage S3/MCP/API integration configurations") public class IntegrationConfigController { private final IntegrationConfigService service; + private final ResourceAccessService accessService; - @GetMapping - public ResponseEntity> list( - @AuthenticationPrincipal User user) { - requireUser(user); - return ResponseEntity.ok( - service.listVisible(user).stream().map(c -> service.toResponse(c, user)).toList()); + // Field-injected so @RequiredArgsConstructor stays a pure collaborator constructor. + @Inject SecurityIdentity securityIdentity; + + @GET + public Response list() { + User user = requirePortalUser(); + return Response.ok( + service.listVisible(user).stream() + .map(c -> service.toResponse(c, user)) + .toList()) + .build(); } - @PostMapping - public ResponseEntity create( - @RequestBody IntegrationConfigRequest request, @AuthenticationPrincipal User user) { - requireUser(user); - return ResponseEntity.ok(service.toResponse(service.create(request, user), user)); + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response create(IntegrationConfigRequest request) { + User user = requirePortalUser(); + return Response.ok(service.toResponse(service.create(request, user), user)).build(); } /** @@ -57,12 +65,12 @@ public class IntegrationConfigController { * inferred client-side: hiding a button is presentation, and the service still refuses the call * regardless of what the client believed. */ - @GetMapping("/capabilities") - public ResponseEntity capabilities( - @AuthenticationPrincipal User user) { - requireUser(user); - return ResponseEntity.ok( - new IntegrationCapabilitiesResponse(service.canAuthorCustomApi(user))); + @GET + @Path("/capabilities") + public Response capabilities() { + User user = requirePortalUser(); + return Response.ok(new IntegrationCapabilitiesResponse(service.canAuthorCustomApi(user))) + .build(); } /** @@ -70,32 +78,50 @@ public class IntegrationConfigController { */ public record IntegrationCapabilitiesResponse(boolean customApi) {} - @GetMapping("/{id}") - public ResponseEntity get( - @PathVariable Long id, @AuthenticationPrincipal User user) { - requireUser(user); - return ResponseEntity.ok(service.toResponse(service.getForUse(id, user), user)); + @GET + @Path("/{id}") + public Response get(@PathParam("id") Long id) { + User user = requirePortalUser(); + return Response.ok(service.toResponse(service.getForUse(id, user), user)).build(); } - @PutMapping("/{id}") - public ResponseEntity update( - @PathVariable Long id, - @RequestBody IntegrationConfigRequest request, - @AuthenticationPrincipal User user) { - requireUser(user); - return ResponseEntity.ok(service.toResponse(service.update(id, request, user), user)); + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + public Response update(@PathParam("id") Long id, IntegrationConfigRequest request) { + User user = requirePortalUser(); + return Response.ok(service.toResponse(service.update(id, request, user), user)).build(); } - @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable Long id, @AuthenticationPrincipal User user) { - requireUser(user); + @DELETE + @Path("/{id}") + public Response delete(@PathParam("id") Long id) { + User user = requirePortalUser(); service.delete(id, user); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } - private void requireUser(User user) { + // Replaces the class-level @PreAuthorize("@resourceAccess.canUsePortal()"): Quarkus has no SpEL + // gate, so every endpoint resolves the caller and clears the portal boundary before any work. + private User requirePortalUser() { + User user = currentUser(); if (user == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); + throw new WebApplicationException( + "Authentication required", Response.Status.UNAUTHORIZED); } + if (!accessService.canAccessPortal(user)) { + throw new WebApplicationException("Portal access required", Response.Status.FORBIDDEN); + } + return user; + } + + // The User entity is the principal, attached by UserSecurityIdentityAugmentor; Spring's + // SecurityContextHolder is never populated on RESTEasy threads. + private User currentUser() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return null; + } + Principal principal = securityIdentity.getPrincipal(); + return principal instanceof User user ? user : null; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java index 6da44eae84..15fc64c908 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java @@ -11,6 +11,7 @@ import java.security.SecureRandom; import java.util.Arrays; import java.util.Base64; import java.util.EnumSet; +import java.util.Optional; import javax.crypto.Cipher; import javax.crypto.KeyGenerator; @@ -20,6 +21,9 @@ import javax.crypto.spec.SecretKeySpec; import org.eclipse.microprofile.config.inject.ConfigProperty; +import io.quarkus.arc.Arc; +import io.quarkus.runtime.Startup; + import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; @@ -30,6 +34,9 @@ import stirling.software.common.configuration.InstallationPathConfig; /** * AES-256-GCM for stored credentials. Key from property, env var, or an auto-generated key file. */ +// @Startup, because nothing injects this bean - it is reached through its static encrypt/decrypt +// from a JPA AttributeConverter, so Arc would otherwise remove it as unused and never run init(). +@Startup @ApplicationScoped @Slf4j public class CredentialEncryption { @@ -47,11 +54,13 @@ public class CredentialEncryption { private final boolean clusterEnabled; public CredentialEncryption( - @ConfigProperty(name = "stirling.security.credentialEncryptionKey", defaultValue = "") - String configuredKey, + // Optional, not defaultValue="": SmallRye Config reads an empty default as absent and + // then fails to convert it to String. + @ConfigProperty(name = "stirling.security.credentialEncryptionKey") + Optional configuredKey, @ConfigProperty(name = "cluster.enabled", defaultValue = "false") boolean clusterEnabled) { - this.configuredKey = configuredKey; + this.configuredKey = configuredKey.orElse(""); this.clusterEnabled = clusterEnabled; } @@ -158,12 +167,25 @@ public class CredentialEncryption { private static SecretKey requireKey() { SecretKey current = key; + if (current == null && Arc.container() != null) { + // A JPA AttributeConverter can encrypt during another bean's @PostConstruct, before + // this one is first injected. Spring created singletons eagerly; Arc does not, and its + // client proxy stays uninitialised until a method is called on it - hence activeKey(). + CredentialEncryption bean = + Arc.container().instance(CredentialEncryption.class).orElse(null); + current = bean == null ? null : bean.activeKey(); + } if (current == null) { throw new IllegalStateException("Credential encryption not initialised"); } return current; } + /** Called through the CDI proxy purely to force {@link #init()} to have run. */ + SecretKey activeKey() { + return key; + } + /** For tests. */ static void initialiseForTesting(SecretKey testKey) { key = testKey; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java index 7ae1a9b306..82d69f1d77 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java @@ -6,24 +6,27 @@ import java.util.List; import java.util.Optional; import org.apache.pdfbox.pdmodel.PDDocument; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.MultipartFile; -import stirling.software.common.model.io.Resource; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; @@ -49,8 +52,8 @@ import tools.jackson.databind.node.ObjectNode; * since it labels documents but does not process them. */ @Slf4j -@RestController -@RequestMapping("/api/v1/integration") +@ApplicationScoped +@Path("/api/v1/integration") @RequiredArgsConstructor @Tag(name = "Integrations", description = "Third-party integration steps.") public class PurviewLabelController { @@ -60,7 +63,9 @@ public class PurviewLabelController { private final TempFileManager tempFileManager; private final ObjectMapper objectMapper; - @PostMapping(value = "/purview-apply-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @Path("/purview-apply-label") + @Consumes(MediaType.MULTIPART_FORM_DATA) @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Apply a Microsoft Purview sensitivity label", @@ -68,15 +73,16 @@ public class PurviewLabelController { "Writes the Purview label metadata (MSIP_Label__*) onto the PDF, so" + " Purview-aware tools recognise the label. Applies the label only;" + " it cannot encrypt, which requires the Microsoft client.") - public ResponseEntity applyLabel( - @RequestParam("fileInput") MultipartFile fileInput, - @RequestParam("connectionId") String connectionId, - @RequestParam("labelId") String labelId, - @RequestParam(value = "labelName", required = false) String labelName, - @RequestParam(value = "method", defaultValue = "STANDARD") String method, - @RequestParam(value = "contentBits", required = false) Integer contentBits) + public Response applyLabel( + @RestForm("fileInput") FileUpload fileInputUpload, + @RestForm("connectionId") String connectionId, + @RestForm("labelId") String labelId, + @RestForm("labelName") String labelName, + @RestForm("method") @DefaultValue("STANDARD") String method, + @RestForm("contentBits") Integer contentBits) throws IOException { + MultipartFile fileInput = FileUploadMultipartFile.of(fileInputUpload); PurviewConnectionSettings settings = settings(connectionId); AssignmentMethod assignment = parseMethod(method); @@ -96,18 +102,21 @@ public class PurviewLabelController { } } - @PostMapping(value = "/purview-read-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @Path("/purview-read-label") + @Consumes(MediaType.MULTIPART_FORM_DATA) @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Read the Microsoft Purview sensitivity label on a PDF", description = "Reports the Purview labels a PDF already carries so a policy can act on" + " them. The document passes through unchanged.") - public ResponseEntity readLabel( - @RequestParam("fileInput") MultipartFile fileInput, - @RequestParam("connectionId") String connectionId) + public Response readLabel( + @RestForm("fileInput") FileUpload fileInputUpload, + @RestForm("connectionId") String connectionId) throws IOException { + MultipartFile fileInput = FileUploadMultipartFile.of(fileInputUpload); PurviewConnectionSettings settings = settings(connectionId); List labels; @@ -117,13 +126,16 @@ public class PurviewLabelController { // The document is returned byte-for-byte rather than re-saved: a read must not perturb the // file it inspected, and a PDFBox round-trip would rewrite its structure. byte[] bytes = fileInput.getBytes(); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_PDF); - headers.setContentDispositionFormData( - "attachment", safeFileName(fileInput.getOriginalFilename())); - headers.setContentLength(bytes.length); - headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReport(labels, settings)); - return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(bytes)); + return Response.ok(bytes) + .type("application/pdf") + .header(HttpHeaders.CONTENT_LENGTH, bytes.length) + .header( + "Content-Disposition", + "form-data; name=\"attachment\"; filename=\"" + + safeFileName(fileInput.getOriginalFilename()) + + "\"") + .header(AiToolResponseHeaders.TOOL_REPORT, buildReport(labels, settings)) + .build(); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java index 59cfd211d1..77f4842e79 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java @@ -2,9 +2,10 @@ package stirling.software.proprietary.integration.repository; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import stirling.software.proprietary.access.model.OwnerScope; import stirling.software.proprietary.integration.model.IntegrationConfig; @@ -12,20 +13,40 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; @ApplicationScoped -public interface IntegrationConfigRepository extends JpaRepository { +public class IntegrationConfigRepository implements PanacheRepositoryBase { - List findByOwnerUser(User ownerUser); + public List findByOwnerUser(User ownerUser) { + return list("ownerUser", ownerUser); + } - List findByOwnerTeam(Team ownerTeam); + public List findByOwnerTeam(Team ownerTeam) { + return list("ownerTeam", ownerTeam); + } - List findByScope(OwnerScope scope); + public List findByScope(OwnerScope scope) { + return list("scope", scope); + } - // Nested path: OwnedResource has a getOwnerTeamId() convenience getter but no such persistent - // attribute, so the plain "...OwnerTeamId" derivation resolves to a phantom property and throws - // UnknownPathException. The underscore forces the real ownerTeam.id association path. - boolean existsByOwnerTeam_Id(Long teamId); + // Filter on the real ownerTeam.id association path: OwnedResource's getOwnerTeamId() is a + // convenience getter, not a persistent attribute, so "ownerTeamId" is a phantom property. + public boolean existsByOwnerTeam_Id(Long teamId) { + return count("ownerTeam.id = ?1", teamId) > 0; + } - void deleteByOwnerUser(User ownerUser); + @Transactional + public void deleteByOwnerUser(User ownerUser) { + delete("ownerUser", ownerUser); + } - void deleteByOwnerTeam_Id(Long teamId); + @Transactional + public void deleteByOwnerTeam_Id(Long teamId) { + delete("ownerTeam.id = ?1", teamId); + } + + /** Spring Data {@code save}: inserts a new config, dirty-checks a managed one. */ + @Transactional + public IntegrationConfig save(IntegrationConfig config) { + persist(config); + return config; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java index b800fe0bff..f3e264cdde 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -5,11 +5,11 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; - import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,7 +35,9 @@ import tools.jackson.databind.ObjectMapper; @ApplicationScoped @RequiredArgsConstructor @Slf4j -@Transactional(readOnly = true) +// jakarta.transaction.Transactional has no readOnly attribute, so read methods use SUPPORTS to +// join an existing transaction rather than force a new one. +@Transactional(Transactional.TxType.SUPPORTS) public class IntegrationConfigService { private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG; @@ -48,8 +50,9 @@ public class IntegrationConfigService { private final ApplicationProperties applicationProperties; // Bean-discovered extension points: features that understand a type contribute its config // schema and report what still references a config, without this module depending on them. - private final List validators; - private final List usageChecks; + // Spring List-of-all-beans -> CDI Instance, which is iterable over every bean of a type. + private final Instance validators; + private final Instance usageChecks; // ---- commands ---- @@ -174,8 +177,9 @@ public class IntegrationConfigService { .flatMap(check -> check.usagesOf(cfg.getId()).stream()) .toList(); if (!usages.isEmpty()) { - throw new ResponseStatusException( - HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages)); + throw new WebApplicationException( + "Integration is in use by: " + String.join(", ", usages), + Response.Status.CONFLICT); } // Drop grants sharing this config so they do not dangle as dead rows. grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId())); @@ -226,7 +230,7 @@ public class IntegrationConfigService { continue; } repository - .findById(cid) + .findByIdOptional(cid) .filter(c -> ownership.canUse(TYPE, c, currentUser)) .ifPresent(c -> byId.put(c.getId(), c)); } @@ -259,7 +263,7 @@ public class IntegrationConfigService { try { validator.validate(config == null ? Map.of() : config); } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); } } } @@ -273,18 +277,19 @@ public class IntegrationConfigService { private IntegrationConfig load(Long id) { return repository - .findById(id) + .findByIdOptional(id) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "Integration not found")); + new WebApplicationException( + "Integration not found", Response.Status.NOT_FOUND)); } private String writeJson(Map config) { try { return OBJECT_MAPPER.writeValueAsString(config == null ? Map.of() : config); } catch (Exception e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid config payload"); + throw new WebApplicationException( + "Invalid config payload", Response.Status.BAD_REQUEST); } } @@ -303,12 +308,12 @@ public class IntegrationConfigService { private T require(T value, String field) { if (value == null || (value instanceof String s && s.isBlank())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required"); + throw new WebApplicationException(field + " is required", Response.Status.BAD_REQUEST); } return value; } - private ResponseStatusException forbidden(String message) { - return new ResponseStatusException(HttpStatus.FORBIDDEN, message); + private WebApplicationException forbidden(String message) { + return new WebApplicationException(message, Response.Status.FORBIDDEN); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamEntityListener.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamEntityListener.java index 574084b5e9..f9a81c4c93 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamEntityListener.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamEntityListener.java @@ -1,26 +1,31 @@ package stirling.software.proprietary.model; -import org.springframework.context.ApplicationEventPublisher; +import io.quarkus.runtime.Startup; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; import jakarta.inject.Inject; import jakarta.persistence.PostPersist; -/** Publishes {@link TeamCreatedEvent} on insert; Spring bridges the publisher via a static. */ +/** + * Publishes {@link TeamCreatedEvent} on insert. JPA owns the listener instance it invokes, so the + * CDI publisher is bridged via a static, set when the bean is created eagerly at startup. + */ +@Startup @ApplicationScoped public class TeamEntityListener { - private static ApplicationEventPublisher publisher; + private static Event publisher; @Inject - void setPublisher(ApplicationEventPublisher applicationEventPublisher) { - TeamEntityListener.publisher = applicationEventPublisher; + void setPublisher(Event teamCreatedEvent) { + TeamEntityListener.publisher = teamCreatedEvent; } @PostPersist public void onCreate(Team team) { if (publisher != null) { - publisher.publishEvent(new TeamCreatedEvent(team.getId(), team.getName())); + publisher.fire(new TeamCreatedEvent(team.getId(), team.getName())); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java index dc978648b6..39a5a56b59 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java @@ -2,17 +2,17 @@ package stirling.software.proprietary.policy.controller; import java.util.List; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; @@ -25,8 +25,8 @@ import stirling.software.proprietary.classification.ClassificationRunBiller; */ @Slf4j @Hidden -@RestController -@RequestMapping("/api/v1/policies") +@ApplicationScoped +@Path("/api/v1/policies") public class ClassificationMeterController { /** Audit step label mirrors the AI classify tool so both paths read alike in the trail. */ @@ -35,21 +35,28 @@ public class ClassificationMeterController { /** Client-supplied count cap: the frontend meters one document per call. */ private static final int MAX_DOCUMENTS = 10_000; - private final ObjectProvider biller; + // ObjectProvider.getIfAvailable() -> Instance.isResolvable()/get(); only SaaS supplies a bean. + private final Instance biller; - public ClassificationMeterController(ObjectProvider biller) { + // Was a handler argument under Spring MVC; JAX-RS takes the servlet request by injection. + private final HttpServletRequest request; + + public ClassificationMeterController( + Instance biller, HttpServletRequest request) { this.biller = biller; + this.request = request; } - @PostMapping("/classify/meter") + @POST + @Path("/classify/meter") + @Consumes(MediaType.APPLICATION_JSON) @Operation( summary = "Meter a client-side classification run", description = "Records billing + audit for a non-AI classification performed in the browser." + " Does no classification itself. Dispatched by the frontend, not for" + " direct use.") - public ResponseEntity meterClassification( - @RequestBody(required = false) ClassifyMeterRequest body, HttpServletRequest request) { + public Response meterClassification(ClassifyMeterRequest body) { int documents = body != null && body.documentCount() != null ? body.documentCount() : 1; if (documents < 1) documents = 1; if (documents > MAX_DOCUMENTS) documents = MAX_DOCUMENTS; @@ -59,10 +66,15 @@ public class ClassificationMeterController { : "Classification"; // Stamp the run so ControllerAuditAspect records it as a policy run, like the AI path. - request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, policyName); - request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, List.of(CLASSIFY_STEP)); + // Best-effort: the servlet request proxy throws UT000048 off an active servlet request. + try { + request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, policyName); + request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, List.of(CLASSIFY_STEP)); + } catch (RuntimeException e) { + log.debug("[classify meter] audit stamp unavailable: {}", e.getMessage()); + } - ClassificationRunBiller runBiller = biller.getIfAvailable(); + ClassificationRunBiller runBiller = biller.isResolvable() ? biller.get() : null; if (runBiller != null) { try { runBiller.recordClassificationRun(documents); @@ -72,7 +84,7 @@ public class ClassificationMeterController { e.getMessage()); } } - return ResponseEntity.accepted().build(); + return Response.accepted().build(); } /** Frontend payload: documents classified, plus the policy name for the audit label. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java index 2070867036..5a847cd255 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java @@ -2,11 +2,15 @@ package stirling.software.proprietary.policy.controller; import java.util.List; -import org.springframework.web.bind.annotation.GetMapping; - +import io.quarkus.arc.profile.IfBuildProfile; import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; import lombok.RequiredArgsConstructor; @@ -20,14 +24,21 @@ import stirling.software.proprietary.policy.config.FolderAccessGuard; * why, without them being editable. The editable roots themselves live under the {@code policies} * settings section. */ +// @AdminApi carries only the OpenAPI @Tag under JAX-RS, so the base path its former +// @RequestMapping supplied is declared here. @AdminApi +@ApplicationScoped +@IfBuildProfile("saas") +@Path("/api/v1/admin/settings") @RolesAllowed("ADMIN") @RequiredArgsConstructor public class FolderAccessSettingsController { private final FolderAccessGuard folderAccessGuard; - @GetMapping("/policies/implied-folder-roots") + @GET + @Path("/policies/implied-folder-roots") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Implied folder roots", description = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 6d58ef065d..e775f442ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -2,39 +2,49 @@ package stirling.software.proprietary.policy.controller; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.jboss.resteasy.reactive.PartType; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.RestStreamElementType; +import org.jboss.resteasy.reactive.server.core.multipart.DefaultFileUpload; +import org.jboss.resteasy.reactive.server.multipart.FormValue; +import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput; import io.github.pixee.security.Filenames; +import io.quarkus.arc.All; +import io.quarkus.arc.profile.IfBuildProfile; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.sse.OutboundSseEvent; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.JobStore; @@ -44,6 +54,7 @@ import stirling.software.common.model.MultipartFile; import stirling.software.common.model.io.FileSystemResource; import stirling.software.common.model.io.Resource; import stirling.software.common.model.job.JobResponse; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.common.model.tool.ToolDiagnostic; import stirling.software.common.service.JobOwnershipService; import stirling.software.common.service.ToolChainValidator; @@ -56,7 +67,6 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.PolicyValidator; -import stirling.software.proprietary.policy.engine.SweepOutcome; import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; @@ -86,13 +96,17 @@ import stirling.software.proprietary.util.SecretMasker; * GET /run/{runId}} for status, download outputs via {@code GET /api/v1/general/files/{fileId}}. */ @Slf4j -@RestController -@RequestMapping("/api/v1/policies") +@ApplicationScoped +@IfBuildProfile("saas") +@Path("/api/v1/policies") @Hidden -@RequiredArgsConstructor @Tag(name = "Policies", description = "Run tool pipelines on the backend") public class PolicyController { + /** Indexed asset part names on the wire: {@code assets[i].key} / {@code assets[i].file}. */ + private static final Pattern ASSET_PART = + Pattern.compile("assets\\[(\\d{1,9})]\\.(?:key|file)"); + private final PolicyRunner policyRunner; private final PolicyRunRegistry runRegistry; private final PolicyStore policyStore; @@ -111,8 +125,54 @@ public class PolicyController { private final JobOwnershipService jobOwnershipService; // Shared job store: lets the run endpoints see runs that executed on other nodes. private final JobStore jobStore; + private final Instance currentRequest; - @PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + // Explicit constructor instead of Lombok so the List injection point can carry + // @All, which is how CDI collects every bean of a type the way Spring's List autowiring did. + @Inject + public PolicyController( + PolicyRunner policyRunner, + PolicyRunRegistry runRegistry, + PolicyStore policyStore, + SourceStore sourceStore, + SourceAccessGuard sourceAccessGuard, + SourceDocCounter docCounter, + PolicyValidator policyValidator, + PolicyAccessGuard policyAccessGuard, + PolicyManagementAuthority policyManagementAuthority, + PolicyTriggerManager policyTriggerManager, + PolicyOverviewService policyOverviewService, + ProcessedLedger processedLedger, + @All List policyTriggers, + ApplicationProperties applicationProperties, + TempFileManager tempFileManager, + JobOwnershipService jobOwnershipService, + JobStore jobStore, + Instance currentRequest) { + this.policyRunner = policyRunner; + this.runRegistry = runRegistry; + this.policyStore = policyStore; + this.sourceStore = sourceStore; + this.sourceAccessGuard = sourceAccessGuard; + this.docCounter = docCounter; + this.policyValidator = policyValidator; + this.policyAccessGuard = policyAccessGuard; + this.policyManagementAuthority = policyManagementAuthority; + this.policyTriggerManager = policyTriggerManager; + this.policyOverviewService = policyOverviewService; + this.processedLedger = processedLedger; + this.policyTriggers = policyTriggers; + this.applicationProperties = applicationProperties; + this.tempFileManager = tempFileManager; + this.jobOwnershipService = jobOwnershipService; + this.jobStore = jobStore; + this.currentRequest = currentRequest; + } + + @POST + @Path("/run") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Run a tool pipeline", description = @@ -121,41 +181,45 @@ public class PolicyController { + " definition as an application/json part named 'json'. Runs the steps" + " in order asynchronously and returns a run id. Poll the run status" + " endpoint and download outputs via /api/v1/general/files/{id}.") - public ResponseEntity> run( - @RequestPart("json") PipelineDefinition definition, - @Valid @ModelAttribute PolicyRunFiles files) + public Response run( + @RestForm("json") @PartType(MediaType.APPLICATION_JSON) PipelineDefinition definition, + MultipartFormDataInput parts) throws IOException { stampPolicyAudit(definition); requireRunnable(definition); validateAdHocRun(definition); - PolicyInputs inputs = toInputs(files); + PolicyInputs inputs = toInputs(toRunFiles(parts)); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP); recordEditorDocs(inputs); - return ResponseEntity.accepted().body(new JobResponse<>(true, handle.runId(), null)); + return Response.accepted(new JobResponse<>(true, handle.runId(), null)).build(); } - @PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @Path("/run/stream") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @RestStreamElementType(MediaType.APPLICATION_JSON) @Operation( summary = "Run a tool pipeline with live progress", description = "Same as /run, but returns Server-Sent Events: a 'step' event as each step" + " starts and completes, then a terminal 'completed', 'failed'," + " 'cancelled', or 'waiting' event carrying the final run view.") - public SseEmitter runStream( - @RequestPart("json") PipelineDefinition definition, - @Valid @ModelAttribute PolicyRunFiles files) + public void runStream( + @RestForm("json") @PartType(MediaType.APPLICATION_JSON) PipelineDefinition definition, + MultipartFormDataInput parts, + @Context Sse sse, + @Context SseEventSink sink) throws IOException { stampPolicyAudit(definition); requireRunnable(definition); validateAdHocRun(definition); - PolicyInputs inputs = toInputs(files); + PolicyInputs inputs = toInputs(toRunFiles(parts)); - SseEmitter emitter = - new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs()); - emitter.onError(e -> log.warn("Policy run SSE emitter error", e)); - - PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter)); + // JAX-RS has no per-sink deadline (Spring's SseEmitter timeout) or error callback; a stream + // whose run never finishes is bounded by the container's HTTP idle timeout instead. + PolicyRunHandle handle = + policyRunner.runAdHoc(definition, inputs, streamListener(sse, sink)); recordEditorDocs(inputs); // whenComplete runs on the worker thread after the run finishes, so the terminal event // never races the step events. @@ -164,25 +228,29 @@ public class PolicyController { (run, throwable) -> { if (throwable != null) { sendEvent( - emitter, + sse, + sink, "failed", Map.of("message", throwable.getMessage())); } else { - sendEvent(emitter, terminalEventName(run), PolicyRunView.of(run)); + sendEvent(sse, sink, terminalEventName(run), PolicyRunView.of(run)); + } + if (!sink.isClosed()) { + sink.close(); } - emitter.complete(); }); - return emitter; } - @GetMapping("/run/{runId}") + @GET + @Path("/run/{runId}") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Get pipeline run status", description = "Returns the current status, step cursor, and output files of a run.") - public ResponseEntity status(@PathVariable String runId) { + public Response status(@PathParam("runId") String runId) { PolicyRun run = runRegistry.get(runId); if (run != null) { - return ResponseEntity.ok(PolicyRunView.of(run)); + return Response.ok(PolicyRunView.of(run)).build(); } // Not local: read the run's shared projection so any node can serve its status. if (ownedByCurrentUser(runId)) { @@ -190,13 +258,15 @@ public class PolicyController { if (entry.isPresent() && entry.get().resultMeta() != null && entry.get().resultMeta().containsKey("policyId")) { - return ResponseEntity.ok(PolicyRunView.ofEntry(entry.get())); + return Response.ok(PolicyRunView.ofEntry(entry.get())).build(); } } - return ResponseEntity.notFound().build(); + return Response.status(Response.Status.NOT_FOUND).build(); } - @GetMapping("/runs") + @GET + @Path("/runs") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "List the caller's stored-policy runs", description = @@ -242,7 +312,10 @@ public class PolicyController { // --- Policy management --- - @PostMapping(value = "/validate", consumes = MediaType.APPLICATION_JSON_VALUE) + @POST + @Path("/validate") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Check whether a chain of steps can run", description = @@ -251,21 +324,22 @@ public class PolicyController { + " chain whose steps cannot run; this answers the same question up" + " front, and also returns the warnings and fan-out notes that saving" + " does not.") - public PipelineValidation.Response validateChain( - @RequestBody PipelineValidation.Request request) { + public PipelineValidation.Response validateChain(PipelineValidation.Request request) { List diagnostics = policyValidator.diagnoseChain(request.steps(), request.sourceFormat()); return new PipelineValidation.Response( !ToolChainValidator.hasErrors(diagnostics), diagnostics); } - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Create or update a policy", description = "Stores a policy (trigger config + steps + output + metadata). A blank id is" + " assigned; returns the stored policy with its id.") - public ResponseEntity savePolicy(@RequestBody Policy policy) { + public Response savePolicy(Policy policy) { requirePolicyEditingAllowed(); Policy owned = withStoredOutputSecrets(resolveOwnership(policy)); requireAccessibleSources(owned); @@ -273,16 +347,18 @@ public class PolicyController { try { policyValidator.validate(owned); } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); } Policy saved = policyStore.save(owned); // Re-sync trigger registrations now so a new/changed folder-watch policy starts being // watched immediately instead of after the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); - return ResponseEntity.ok(withMaskedOutputSecrets(saved)); + return Response.ok(withMaskedOutputSecrets(saved)).build(); } - @PutMapping("/order") + @PUT + @Path("/order") + @Consumes(MediaType.APPLICATION_JSON) @Operation( summary = "Set the team's policy run order", description = @@ -290,10 +366,10 @@ public class PolicyController { + " policy ids (position → order). The per-trigger order shown in the UI" + " is this one sequence filtered by trigger. Team-leader/admin only;" + " ids outside the caller's team are ignored.") - public ResponseEntity reorderPolicies(@RequestBody List orderedPolicyIds) { + public Response reorderPolicies(List orderedPolicyIds) { requirePolicyEditingAllowed(); policyStore.reorder(policyAccessGuard.teamForNewPolicy(), orderedPolicyIds); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } /** @@ -306,8 +382,8 @@ public class PolicyController { boolean accessible = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).isPresent(); if (!accessible) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Unknown or inaccessible source: " + sourceId); + throw new WebApplicationException( + "Unknown or inaccessible source: " + sourceId, Response.Status.BAD_REQUEST); } } } @@ -329,19 +405,19 @@ public class PolicyController { .filter(sourceAccessGuard::canAccess) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.BAD_REQUEST, + new WebApplicationException( "Unknown or inaccessible output source: " - + outputId)); + + outputId, + Response.Status.BAD_REQUEST)); if (EditorSource.TYPE.equals(destination.type())) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "The editor can't be used as an output destination"); + throw new WebApplicationException( + "The editor can't be used as an output destination", + Response.Status.BAD_REQUEST); } try { policyValidator.validateOutput(destination.toOutputSpec()); } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); } } } @@ -358,7 +434,8 @@ public class PolicyController { Policy existing = policyStore.get(id).orElse(null); if (existing != null) { if (!policyAccessGuard.canAccess(existing)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No policy: " + id); + throw new WebApplicationException( + "No policy: " + id, Response.Status.NOT_FOUND); } return withOwnerAndTeam(incoming, existing.owner(), existing.teamId()); } @@ -432,13 +509,14 @@ public class PolicyController { return; } if (!policyManagementAuthority.canEditPolicies()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Policies may only be created or modified by a team leader"); + throw new WebApplicationException( + "Policies may only be created or modified by a team leader", + Response.Status.FORBIDDEN); } } - @GetMapping + @GET + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "List policies", description = @@ -451,7 +529,9 @@ public class PolicyController { .toList(); } - @GetMapping("/overview") + @GET + @Path("/overview") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Pipelines overview", description = @@ -462,7 +542,9 @@ public class PolicyController { return policyOverviewService.overview(); } - @GetMapping("/triggers") + @GET + @Path("/triggers") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "List available triggers", description = @@ -476,25 +558,28 @@ public class PolicyController { .toList(); } - @GetMapping("/{policyId}") + @GET + @Path("/{policyId}") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Get a policy by id", description = "Secret-bearing output options are returned as a redaction sentinel, never" + " their stored values; an edit that sends the sentinel back keeps" + " them.") - public ResponseEntity getPolicy(@PathVariable String policyId) { + public Response getPolicy(@PathParam("policyId") String policyId) { return policyStore .get(policyId) .filter(policyAccessGuard::canAccess) .map(PolicyController::withMaskedOutputSecrets) - .map(ResponseEntity::ok) - .orElseGet(() -> ResponseEntity.notFound().build()); + .map(masked -> Response.ok(masked).build()) + .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build()); } - @DeleteMapping("/{policyId}") + @DELETE + @Path("/{policyId}") @Operation(summary = "Delete a policy by id") - public ResponseEntity deletePolicy(@PathVariable String policyId) { + public Response deletePolicy(@PathParam("policyId") String policyId) { requirePolicyEditingAllowed(); // Scope to the caller's team: a policy in another team reads as not-found. boolean accessible = @@ -504,31 +589,35 @@ public class PolicyController { // Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open // until the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } - return ResponseEntity.notFound().build(); + return Response.status(Response.Status.NOT_FOUND).build(); } - @DeleteMapping("/{policyId}/processed-history") + @DELETE + @Path("/{policyId}/processed-history") @Operation( summary = "Clear a policy's processed-file history", description = "Forgets which source files this policy has already processed, so its next" + " sweep reprocesses everything currently in its sources. Does not" + " touch the files themselves.") - public ResponseEntity clearProcessedHistory(@PathVariable String policyId) { + public Response clearProcessedHistory(@PathParam("policyId") String policyId) { requirePolicyEditingAllowed(); // Scope to the caller's team: a policy in another team reads as not-found. boolean accessible = policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); if (!accessible) { - return ResponseEntity.notFound().build(); + return Response.status(Response.Status.NOT_FOUND).build(); } processedLedger.clearPolicy(policyId); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } - @PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @Path("/{policyId}/run") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Run a stored policy", description = @@ -536,8 +625,8 @@ public class PolicyController { + " under 'fileInput', supporting files under 'assets[i].key' /" + " 'assets[i].file'). Runs regardless of the policy's enabled flag," + " which only gates automatic triggering. Returns a run id.") - public ResponseEntity> runStoredPolicy( - @PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files) + public Response runStoredPolicy( + @PathParam("policyId") String policyId, MultipartFormDataInput parts) throws IOException { Policy policy = policyStore @@ -545,15 +634,18 @@ public class PolicyController { .filter(policyAccessGuard::canAccess) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "No policy: " + policyId)); + new WebApplicationException( + "No policy: " + policyId, + Response.Status.NOT_FOUND)); stampPolicyAudit(policy.toDefinition()); - PolicyInputs inputs = toInputs(files); + PolicyInputs inputs = toInputs(toRunFiles(parts)); String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId(); - return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); + return Response.accepted(new JobResponse<>(true, runId, null)).build(); } - @PostMapping("/{policyId}/trigger") + @POST + @Path("/{policyId}/trigger") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Run a stored policy against its sources", description = @@ -562,22 +654,28 @@ public class PolicyController { + " the ids of the runs started (poll the run-status endpoint for each)" + " plus what the sweep skipped - already-processed, parked-by-failure," + " and in-flight counts - so an empty result explains itself.") - public ResponseEntity trigger(@PathVariable String policyId) { + public Response trigger(@PathParam("policyId") String policyId) { Policy policy = policyStore .get(policyId) .filter(policyAccessGuard::canAccess) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "No policy: " + policyId)); - return ResponseEntity.accepted().body(policyRunner.run(policy)); + new WebApplicationException( + "No policy: " + policyId, + Response.Status.NOT_FOUND)); + return Response.accepted(policyRunner.run(policy)).build(); } private static void requireRunnable(PipelineDefinition definition) { + // An absent 'json' part binds as null, where Spring's @RequestPart rejected the request. + if (definition == null) { + throw new WebApplicationException( + "Required part 'json' is not present", Response.Status.BAD_REQUEST); + } if (definition.steps().isEmpty()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Pipeline definition has no steps"); + throw new WebApplicationException( + "Pipeline definition has no steps", Response.Status.BAD_REQUEST); } } @@ -586,13 +684,11 @@ public class PolicyController { * can label the event as the policy it ran (rather than the generic {@code /run} endpoint) and * record which tools it executed. No-op outside a web request. */ - private static void stampPolicyAudit(PipelineDefinition definition) { - if (definition == null - || !(RequestContextHolder.getRequestAttributes() - instanceof ServletRequestAttributes attrs)) { + private void stampPolicyAudit(PipelineDefinition definition) { + HttpServletRequest request = currentServletRequest(); + if (definition == null || request == null) { return; } - HttpServletRequest request = attrs.getRequest(); if (definition.name() != null && !definition.name().isBlank()) { request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, definition.name()); } @@ -606,6 +702,24 @@ public class PolicyController { } } + /** + * The request the audit aspect reads these attributes back off, or null when none is active - + * resolving the proxy throws off a servlet request, which is the old {@code + * RequestContextHolder} null case. + */ + private HttpServletRequest currentServletRequest() { + try { + if (currentRequest.isUnsatisfied()) { + return null; + } + HttpServletRequest request = currentRequest.get(); + request.getRequestURI(); + return request; + } catch (RuntimeException e) { + return null; + } + } + /** * Authorization-check an ad-hoc run's steps and output while the caller's principal is present * (this request thread). The worker thread that later runs and delivers carries no security @@ -625,7 +739,7 @@ public class PolicyController { policyValidator.validateOutput(output); } } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); } } @@ -640,6 +754,75 @@ public class PolicyController { inputs.primary().size()); } + /** + * Bind the run's multipart parts by hand: RESTEasy Reactive cannot map Spring's indexed {@code + * assets[i].key} / {@code assets[i].file} fields onto a list of POJOs carrying files. + */ + private static PolicyRunFiles toRunFiles(MultipartFormDataInput input) { + PolicyRunFiles files = new PolicyRunFiles(); + if (input == null || input.getValues() == null) { + return files; + } + Map> parts = input.getValues(); + files.setFileInput(filesUnder("fileInput", parts)); + files.setAssets(assetsFrom(parts)); + return files; + } + + /** Every file part sent under the given field name, in the order the client sent them. */ + private static List filesUnder( + String partName, Map> parts) { + List uploads = new ArrayList<>(); + for (FormValue value : parts.getOrDefault(partName, List.of())) { + if (value.isFileItem()) { + uploads.add(FileUploadMultipartFile.of(new DefaultFileUpload(partName, value))); + } + } + return uploads; + } + + /** + * The keyed supporting assets. Every index up to the highest one sent must carry both a key and + * a file, which is what the bound POJO's {@code @NotBlank} / {@code @NotNull} rejected. + */ + private static List assetsFrom(Map> parts) { + int highestIndex = -1; + for (String partName : parts.keySet()) { + Matcher matcher = ASSET_PART.matcher(partName); + if (matcher.matches()) { + highestIndex = Math.max(highestIndex, Integer.parseInt(matcher.group(1))); + } + } + List assets = new ArrayList<>(); + for (int index = 0; index <= highestIndex; index++) { + NamedAsset asset = new NamedAsset(); + asset.setKey(requireText("assets[" + index + "].key", parts)); + asset.setFile(requireFile("assets[" + index + "].file", parts)); + assets.add(asset); + } + return assets; + } + + private static String requireText(String partName, Map> parts) { + for (FormValue value : parts.getOrDefault(partName, List.of())) { + if (!value.isFileItem() && value.getValue() != null && !value.getValue().isBlank()) { + return value.getValue(); + } + } + throw new WebApplicationException( + "Missing multipart field: " + partName, Response.Status.BAD_REQUEST); + } + + private static MultipartFile requireFile( + String partName, Map> parts) { + List uploads = filesUnder(partName, parts); + if (uploads.isEmpty()) { + throw new WebApplicationException( + "Missing multipart file: " + partName, Response.Status.BAD_REQUEST); + } + return uploads.get(0); + } + /** * Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the * named supporting-file store, where each asset's {@code key} is the name a step references @@ -660,16 +843,17 @@ public class PolicyController { return new PolicyInputs(primary, supportingFiles); } - private PolicyProgressListener streamListener(SseEmitter emitter) { + private PolicyProgressListener streamListener(Sse sse, SseEventSink sink) { return new PolicyProgressListener() { @Override public void onStepStart(int stepIndex, int stepCount, String operation) { - sendEvent(emitter, "step", stepEvent("started", stepIndex, stepCount, operation)); + sendEvent(sse, sink, "step", stepEvent("started", stepIndex, stepCount, operation)); } @Override public void onStepComplete(int stepIndex, int stepCount, String operation) { - sendEvent(emitter, "step", stepEvent("completed", stepIndex, stepCount, operation)); + sendEvent( + sse, sink, "step", stepEvent("completed", stepIndex, stepCount, operation)); } }; } @@ -694,10 +878,17 @@ public class PolicyController { }; } - private void sendEvent(SseEmitter emitter, String name, Object data) { + private void sendEvent(Sse sse, SseEventSink sink, String name, Object data) { + OutboundSseEvent event = + sse.newEventBuilder() + .name(name) + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(data) + .build(); try { - emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON)); - } catch (IOException | IllegalStateException e) { + // Join so a failed delivery surfaces here, the way SseEmitter#send threw outright. + sink.send(event).toCompletableFuture().join(); + } catch (RuntimeException e) { // Client gone or emitter closed. The run continues and outputs stay downloadable via // the job endpoints. log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java index 4c8b5f40a5..f759632f47 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java @@ -1,7 +1,5 @@ package stirling.software.proprietary.policy.controller; -import org.springframework.web.servlet.HandlerMapping; - import jakarta.servlet.http.HttpServletRequest; /** @@ -23,14 +21,18 @@ public final class PolicyRunRoutes { private static final String BASE = "/api/v1/policies"; + /** Same attribute name {@code AiToolRoutes} reads, so the two gates can't drift. */ + private static final String BEST_MATCHING_PATTERN_ATTRIBUTE = + "org.springframework.web.servlet.HandlerMapping.bestMatchingPattern"; + private PolicyRunRoutes() {} /** * True when the request resolved to a policy execute endpoint. Prefers the matched route - * pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI. + * pattern when one is set (context-path independent) and falls back to the raw request URI. */ public static boolean matches(HttpServletRequest request) { - Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + Object pattern = request.getAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE); String path = pattern instanceof String s ? s : request.getRequestURI(); String rel = relativeToBase(path); return rel != null && isExecuteRoute(rel); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 962e81b83b..0136389899 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -103,6 +103,7 @@ public class PolicyEngine { JobOwnershipService jobOwnershipService, UserService userService, @All List outputSinks, + PolicyOutputResolver outputResolver, ResourceMonitor resourceMonitor, JobQueue jobQueue) { this.stepExecutor = stepExecutor; @@ -112,6 +113,7 @@ public class PolicyEngine { this.jobOwnershipService = jobOwnershipService; this.userService = userService; this.outputSinks = outputSinks; + this.outputResolver = outputResolver; this.resourceMonitor = resourceMonitor; this.jobQueue = jobQueue; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 4a3f4d4752..7beb64d99e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -45,10 +45,20 @@ public class PolicyRunner { private final SourceDocCounter docCounter; private final ProcessedLedger processedLedger; + // Written out rather than @RequiredArgsConstructor because Lombok cannot stamp @All onto the + // InputSource list, which is how Arc injects every bean of a type. @jakarta.inject.Inject - public PolicyRunner(PolicyEngine policyEngine, @All List inputSources) { + public PolicyRunner( + PolicyEngine policyEngine, + @All List inputSources, + SourceStore sourceStore, + SourceDocCounter docCounter, + ProcessedLedger processedLedger) { this.policyEngine = policyEngine; this.inputSources = inputSources; + this.sourceStore = sourceStore; + this.docCounter = docCounter; + this.processedLedger = processedLedger; } /** Full-listing sweep over every input: resolve each source, then reconcile the ledger. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java index ca793cd113..aed9f31541 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java @@ -1,13 +1,12 @@ package stirling.software.proprietary.policy.input; +import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; -import org.springframework.core.io.AbstractResource; - import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -230,7 +229,7 @@ public class S3InputSource implements InputSource { * reads a different version than the sweep claimed (a swapped object fails the read with a * precondition error and the new version is claimed by a later sweep). */ - private static final class S3ObjectResource extends AbstractResource { + private static final class S3ObjectResource implements Resource { private final S3Client client; private final String bucket; @@ -278,8 +277,14 @@ public class S3InputSource implements InputSource { return key.substring(key.lastIndexOf('/') + 1); } + /** Bucket-only: there is no local file to hand out, callers must stream it. */ @Override - public String getDescription() { + public File getFile() throws IOException { + throw new IOException(getDescription() + " is not backed by a local file"); + } + + /** A helper now, not an override: the Resource shim carries no description. */ + private String getDescription() { return "S3 object " + S3Identities.identity(bucket, key); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java index eeb627be1e..b3ef0c51bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java @@ -6,10 +6,10 @@ import java.util.List; import java.util.Map; import java.util.function.Supplier; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; +import io.quarkus.runtime.StartupEvent; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.persistence.PersistenceException; @@ -200,8 +200,13 @@ public class JpaProcessedLedger implements ProcessedLedger { repository.deleteByPolicy(policyId); } + // Spring hung @EventListener(ApplicationReadyEvent) on recoverInterrupted() itself; a CDI + // observer needs the event parameter, and the interface method has none, hence the delegate. + void onStart(@Observes StartupEvent event) { + recoverInterrupted(); + } + @Override - @EventListener(ApplicationReadyEvent.class) public void recoverInterrupted() { int recovered = repository.markAllProcessingInterrupted(nowMillis.get()); if (recovered > 0) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java index 2e98025aff..a8eeb9434e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java @@ -2,8 +2,6 @@ package stirling.software.proprietary.policy.ledger; import java.io.Serializable; -import org.springframework.data.domain.Persistable; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -21,9 +19,9 @@ import lombok.Setter; /** * One processed-file ledger row: the version a policy last settled a file at, and where it is in * the claim lifecycle. Keyed by SHA-256 of the source-owned identity so any identity length fits a - * fixed-width index. {@code isNew} is always true: the entity is only saved for fresh inserts - * (everything else is a conditional update), so a lost insert race surfaces as a constraint - * violation rather than a silent merge. + * fixed-width index. Rows are only ever inserted, never merged: the entity is saved for fresh + * inserts alone (everything else is a conditional update), so a lost insert race surfaces as a + * constraint violation rather than a silent merge. */ @Entity @Table( @@ -39,7 +37,7 @@ import lombok.Setter; @NoArgsConstructor @Getter @Setter -public class ProcessedFileEntity implements Serializable, Persistable { +public class ProcessedFileEntity implements Serializable { private static final long serialVersionUID = 1L; @@ -92,15 +90,8 @@ public class ProcessedFileEntity implements Serializable, Persistable { +public class ProcessedFileRepository + implements PanacheRepositoryBase { /** * Re-claim a settled row at a new gate without content verification; clears the stored hash, * which described content this claim never checked. */ - @Modifying @Transactional - @Query( - "update ProcessedFileEntity e set e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," - + " e.signature = :gate, e.contentHash = null, e.attempts = 1," - + " e.lastSeen = :now, e.updatedAt = :now" - + " where e.policyId = :policyId and e.identityHash = :identityHash" - + " and e.status <>" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" - + " and e.signature <> :gate") - int reclaimAtNewGate( - @Param("policyId") String policyId, - @Param("identityHash") String identityHash, - @Param("gate") String gate, - @Param("now") long now); + public int reclaimAtNewGate(String policyId, String identityHash, String gate, long now) { + return update( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.contentHash = null, e.attempts = 1," + + " e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and e.signature <> :gate", + Parameters.with("policyId", policyId) + .and("identityHash", identityHash) + .and("gate", gate) + .and("now", now)); + } /** Re-claim a settled row whose content verifiably changed (or was never hashed). */ - @Modifying @Transactional - @Query( - "update ProcessedFileEntity e set e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," - + " e.signature = :gate, e.contentHash = :contentHash, e.attempts = 1," - + " e.lastSeen = :now, e.updatedAt = :now" - + " where e.policyId = :policyId and e.identityHash = :identityHash" - + " and e.status <>" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" - + " and (e.contentHash is null or e.contentHash <> :contentHash)") - int reclaimAtNewContent( - @Param("policyId") String policyId, - @Param("identityHash") String identityHash, - @Param("gate") String gate, - @Param("contentHash") String contentHash, - @Param("now") long now); + public int reclaimAtNewContent( + String policyId, String identityHash, String gate, String contentHash, long now) { + return update( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.contentHash = :contentHash, e.attempts = 1," + + " e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and (e.contentHash is null or e.contentHash <> :contentHash)", + Parameters.with("policyId", policyId) + .and("identityHash", identityHash) + .and("gate", gate) + .and("contentHash", contentHash) + .and("now", now)); + } /** The gate moved but the content did not: track the new gate without changing status. */ - @Modifying @Transactional - @Query( - "update ProcessedFileEntity e set e.signature = :gate, e.lastSeen = :now," - + " e.updatedAt = :now" - + " where e.policyId = :policyId and e.identityHash = :identityHash" - + " and e.status <>" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" - + " and e.contentHash = :contentHash and e.signature <> :gate") - int refreshGate( - @Param("policyId") String policyId, - @Param("identityHash") String identityHash, - @Param("gate") String gate, - @Param("contentHash") String contentHash, - @Param("now") long now); + public int refreshGate( + String policyId, String identityHash, String gate, String contentHash, long now) { + return update( + "update ProcessedFileEntity e set e.signature = :gate, e.lastSeen = :now," + + " e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and e.contentHash = :contentHash and e.signature <> :gate", + Parameters.with("policyId", policyId) + .and("identityHash", identityHash) + .and("gate", gate) + .and("contentHash", contentHash) + .and("now", now)); + } /** Bounded retry of an INTERRUPTED row at the same gate. */ - @Modifying @Transactional - @Query( - "update ProcessedFileEntity e set e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," - + " e.attempts = e.attempts + 1, e.lastSeen = :now, e.updatedAt = :now" - + " where e.policyId = :policyId and e.identityHash = :identityHash" - + " and e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED" - + " and e.signature = :gate and e.attempts < :maxAttempts") - int retryInterruptedAtGate( - @Param("policyId") String policyId, - @Param("identityHash") String identityHash, - @Param("gate") String gate, - @Param("maxAttempts") int maxAttempts, - @Param("now") long now); + public int retryInterruptedAtGate( + String policyId, String identityHash, String gate, int maxAttempts, long now) { + return update( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.attempts = e.attempts + 1, e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED" + + " and e.signature = :gate and e.attempts < :maxAttempts", + Parameters.with("policyId", policyId) + .and("identityHash", identityHash) + .and("gate", gate) + .and("maxAttempts", maxAttempts) + .and("now", now)); + } /** Bounded retry of an INTERRUPTED row whose gate moved but whose content is unchanged. */ - @Modifying @Transactional - @Query( - "update ProcessedFileEntity e set e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," - + " e.signature = :gate, e.attempts = e.attempts + 1, e.lastSeen = :now," - + " e.updatedAt = :now" - + " where e.policyId = :policyId and e.identityHash = :identityHash" - + " and e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED" - + " and e.contentHash = :contentHash and e.attempts < :maxAttempts") - int retryInterruptedSameContent( - @Param("policyId") String policyId, - @Param("identityHash") String identityHash, - @Param("gate") String gate, - @Param("contentHash") String contentHash, - @Param("maxAttempts") int maxAttempts, - @Param("now") long now); + public int retryInterruptedSameContent( + String policyId, + String identityHash, + String gate, + String contentHash, + int maxAttempts, + long now) { + return update( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.attempts = e.attempts + 1, e.lastSeen = :now," + + " e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED" + + " and e.contentHash = :contentHash and e.attempts < :maxAttempts", + Parameters.with("policyId", policyId) + .and("identityHash", identityHash) + .and("gate", gate) + .and("contentHash", contentHash) + .and("maxAttempts", maxAttempts) + .and("now", now)); + } /** * Unconditional settle (only the claiming run settles a row); returns 0 when the row was * removed mid-run so the caller re-inserts. */ - @Modifying @Transactional - @Query( - "update ProcessedFileEntity e set e.status = :status, e.signature = :gate," - + " e.contentHash = :contentHash, e.lastSeen = :now, e.updatedAt = :now" - + " where e.policyId = :policyId and e.identityHash = :identityHash") - int settle( - @Param("policyId") String policyId, - @Param("identityHash") String identityHash, - @Param("gate") String gate, - @Param("contentHash") String contentHash, - @Param("status") ProcessedFileStatus status, - @Param("now") long now); + public int settle( + String policyId, + String identityHash, + String gate, + String contentHash, + ProcessedFileStatus status, + long now) { + return update( + "update ProcessedFileEntity e set e.status = :status, e.signature = :gate," + + " e.contentHash = :contentHash, e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash", + Parameters.with("policyId", policyId) + .and("identityHash", identityHash) + .and("gate", gate) + .and("contentHash", contentHash) + .and("status", status) + .and("now", now)); + } /** Whether any policy's row at this identity is in a state other than {@code status}. */ - boolean existsByIdentityHashAndStatusNot(String identityHash, ProcessedFileStatus status); + @Transactional + public boolean existsByIdentityHashAndStatusNot( + String identityHash, ProcessedFileStatus status) { + return count("identityHash = ?1 and status <> ?2", identityHash, status) > 0; + } /** One policy's rows across a chunk of identity hashes, for a sweep's claim snapshot. */ - List findByPolicyIdAndIdentityHashIn( - String policyId, Collection identityHashes); + @Transactional + public List findByPolicyIdAndIdentityHashIn( + String policyId, Collection identityHashes) { + return list("policyId = ?1 and identityHash in ?2", policyId, identityHashes); + } /** * Remove an output record whose rename never landed, only while still settled exactly as * recorded; a row a claim has since taken over is left alone. */ - @Modifying @Transactional - @Query( - "delete from ProcessedFileEntity e where e.policyId = :policyId" - + " and e.identityHash = :identityHash and e.signature = :gate" - + " and e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.DONE") - int deleteDoneAt( - @Param("policyId") String policyId, - @Param("identityHash") String identityHash, - @Param("gate") String gate); + public int deleteDoneAt(String policyId, String identityHash, String gate) { + return (int) + delete( + "delete from ProcessedFileEntity e where e.policyId = :policyId" + + " and e.identityHash = :identityHash and e.signature = :gate" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.DONE", + Parameters.with("policyId", policyId) + .and("identityHash", identityHash) + .and("gate", gate)); + } /** Stamp presence for the given identities; chunked by the caller for very large folders. */ - @Modifying @Transactional - @Query( - "update ProcessedFileEntity e set e.lastSeen = :now" - + " where e.policyId = :policyId and e.identityHash in :identityHashes") - int stampSeen( - @Param("policyId") String policyId, - @Param("identityHashes") Collection identityHashes, - @Param("now") long now); + public int stampSeen(String policyId, Collection identityHashes, long now) { + return update( + "update ProcessedFileEntity e set e.lastSeen = :now" + + " where e.policyId = :policyId and e.identityHash in :identityHashes", + Parameters.with("policyId", policyId) + .and("identityHashes", identityHashes) + .and("now", now)); + } /** * Presence cleanup: remove rows not stamped since the sweep began, keeping in-flight claims. */ - @Modifying @Transactional - @Query( - "delete from ProcessedFileEntity e where e.policyId = :policyId" - + " and e.lastSeen < :cutoff and e.status <>" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING") - int deleteUnseen(@Param("policyId") String policyId, @Param("cutoff") long cutoff); + public int deleteUnseen(String policyId, long cutoff) { + return (int) + delete( + "delete from ProcessedFileEntity e where e.policyId = :policyId" + + " and e.lastSeen < :cutoff and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING", + Parameters.with("policyId", policyId).and("cutoff", cutoff)); + } - @Modifying @Transactional - @Query("delete from ProcessedFileEntity e where e.policyId = :policyId") - int deleteByPolicy(@Param("policyId") String policyId); + public int deleteByPolicy(String policyId) { + return (int) + delete( + "delete from ProcessedFileEntity e where e.policyId = :policyId", + Parameters.with("policyId", policyId)); + } /** Boot recovery: after a restart every PROCESSING row is stale (single node). */ - @Modifying @Transactional - @Query( - "update ProcessedFileEntity e set e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED," - + " e.updatedAt = :now" - + " where e.status =" - + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING") - int markAllProcessingInterrupted(@Param("now") long now); + public int markAllProcessingInterrupted(long now) { + return update( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED," + + " e.updatedAt = :now" + + " where e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING", + Parameters.with("now", now)); + } + + /** + * Spring Data's {@code saveAndFlush}: persist always INSERTs, as the entity's insert-only + * contract requires, and the flush surfaces a concurrent insert's constraint violation here. + */ + @Transactional + public ProcessedFileEntity saveAndFlush(ProcessedFileEntity row) { + persistAndFlush(row); + return row; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java index 0bbf4bc0cd..b46331addb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java @@ -1,8 +1,33 @@ package stirling.software.proprietary.policy.migration; -import org.springframework.data.jpa.repository.JpaRepository; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; @ApplicationScoped -public interface CompletedMigrationRepository extends JpaRepository {} +public class CompletedMigrationRepository + implements PanacheRepositoryBase { + + /** Spring Data {@code existsById(id)} -> Panache count by id. */ + public boolean existsById(String id) { + return count("id", id) > 0; + } + + /** + * Spring Data {@code save}: an assigned string id arrives detached, so merge rather than + * blind-insert; the flush surfaces a duplicate-key clash here, where markDone catches it. + */ + @Transactional + public CompletedMigration save(CompletedMigration marker) { + CompletedMigration saved; + if (isPersistent(marker)) { + persist(marker); + saved = marker; + } else { + saved = getEntityManager().merge(marker); + } + flush(); + return saved; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkConnectionResolver.java index 202465295d..27b990b12f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkConnectionResolver.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/NetworkConnectionResolver.java @@ -55,7 +55,7 @@ public class NetworkConnectionResolver { } IntegrationConfig connection = connections - .findById(connectionId) + .findByIdOptional(connectionId) .filter(cfg -> cfg.getIntegrationType() == IntegrationType.NETWORK) .filter(this::usableByCurrentUser) // Existence and access collapse into one error so a caller cannot tell diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java index 12b8c963bc..8891b194a0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java @@ -4,10 +4,13 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; +import io.quarkus.arc.profile.IfBuildProfile; +import io.quarkus.runtime.StartupEvent; +import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.interceptor.Interceptor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,6 +37,7 @@ import stirling.software.proprietary.policy.store.PolicyStore; */ @Slf4j @ApplicationScoped +@IfBuildProfile("saas") @RequiredArgsConstructor public class PolicyInlineOutputMigration { @@ -55,13 +59,18 @@ public class PolicyInlineOutputMigration { private final SourceStore sourceStore; private final CompletedMigrations completedMigrations; - // Runs after EmbeddedS3CredentialMigration (@Order(1)) so any legacy S3 output has already had - // its embedded credentials extracted into a connection; the Source created here then references - // that connection rather than copying credentials into source_json. Not wrapped in a single - // transaction: each store write is its own (idempotent) commit, so a crash mid-run just re-runs - // next boot, and the marker below is written only once the whole pass succeeds. - @Order(2) - @EventListener(ApplicationReadyEvent.class) + // Ordered above the CDI default (APPLICATION+500) so startup reaches this only after + // EmbeddedS3CredentialMigration, the way Spring's @Order(1)/@Order(2) pair did. + void onStart(@Observes @Priority(Interceptor.Priority.APPLICATION + 600) StartupEvent event) { + migrate(); + } + + // Runs after EmbeddedS3CredentialMigration (see the observer priority above) so any legacy S3 + // output has already had its embedded credentials extracted into a connection; the Source + // created here then references that connection rather than copying credentials into + // source_json. Not wrapped in a single transaction: each store write is its own (idempotent) + // commit, so a crash mid-run just re-runs next boot, and the marker below is written only once + // the whole pass succeeds. public void migrate() { if (completedMigrations.isDone(MIGRATION_ID)) { return; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java index 3db8dbb3df..e1d6ae9727 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.output; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.Path; import java.security.DigestOutputStream; @@ -13,9 +14,6 @@ import java.util.HexFormat; import java.util.List; import java.util.UUID; -import org.springframework.http.MediaType; -import org.springframework.http.MediaTypeFactory; - import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -57,6 +55,7 @@ import software.amazon.awssdk.services.s3.model.S3Exception; public class S3OutputSink implements PolicyOutputSink { private static final String TYPE = "s3"; + private static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; private final S3ConnectionPool connectionPool; private final S3ConnectionResolver connectionResolver; @@ -97,10 +96,10 @@ public class S3OutputSink implements PolicyOutputSink { String predictedGate = stage(resource, staged, delivery.policyId() != null); long size = Files.size(staged); String key = upload(delivery, client, config, name, staged, predictedGate); - String contentType = - MediaTypeFactory.getMediaType(name) - .orElse(MediaType.APPLICATION_OCTET_STREAM) - .toString(); + // jakarta.ws.rs has no MediaTypeFactory equivalent, so guess from the extension + // with the JDK and keep Spring's application/octet-stream fallback. + String guessed = URLConnection.guessContentTypeFromName(name); + String contentType = guessed != null ? guessed : APPLICATION_OCTET_STREAM; results.add( ResultFile.builder() .fileId(UUID.randomUUID().toString()) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index 79ac2e0c18..c8b8ac0c46 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import io.quarkus.arc.profile.IfBuildProfile; + import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -27,6 +29,7 @@ import stirling.software.proprietary.policy.store.PolicyStore; * user-facing Policies page builds only a friendly subset of the same backend policies. */ @ApplicationScoped +@IfBuildProfile("saas") @RequiredArgsConstructor public class PolicyOverviewService { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java index 11a38b3437..b5a5dacb12 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java @@ -4,10 +4,14 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; +import io.quarkus.arc.profile.IfBuildProfile; +import io.quarkus.runtime.StartupEvent; +import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.interceptor.Interceptor; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -43,6 +47,7 @@ import tools.jackson.databind.ObjectMapper; */ @Slf4j @ApplicationScoped +@IfBuildProfile("saas") @RequiredArgsConstructor public class EmbeddedS3CredentialMigration { @@ -64,11 +69,16 @@ public class EmbeddedS3CredentialMigration { // Must run before PolicyInlineOutputMigration: that migration copies a policy's inline output // options into a Source, so embedded S3 credentials have to be extracted into a connection here - // first, or they would be copied verbatim (plaintext) into the new source row. Each rewrite is - // its own idempotent commit (dedup by credential key), so a crash mid-run just re-runs next - // boot; the completion marker below is written only once the whole pass succeeds. - @Order(1) - @EventListener(ApplicationReadyEvent.class) + // first, or they would be copied verbatim (plaintext) into the new source row. Spring's + // @Order(1) maps to an observer @Priority just after the config overlay (APPLICATION) and well + // below the default 2500, which keeps that order. + // @Transactional: Panache needs an ambient transaction and the StartupEvent thread has none. + // The rewrites and the marker then commit together; a crash mid-run just re-runs next boot. + @Transactional + void onStart(@Observes @Priority(Interceptor.Priority.APPLICATION + 1) StartupEvent event) { + migrate(); + } + public void migrate() { if (completedMigrations.isDone(MIGRATION_ID)) { return; @@ -123,7 +133,7 @@ public class EmbeddedS3CredentialMigration { connection.setEnabled(true); connection.setLocked(false); connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY); - Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null); + Team team = teamId == null ? null : teamRepository.findByIdOptional(teamId).orElse(null); if (team != null) { connection.setScope(OwnerScope.TEAM); connection.setOwnerTeam(team); @@ -164,7 +174,7 @@ public class EmbeddedS3CredentialMigration { private Map indexExistingConnections() { Map byKey = new LinkedHashMap<>(); - for (IntegrationConfig connection : connections.findAll()) { + for (IntegrationConfig connection : connections.listAll()) { if (connection.getIntegrationType() != IntegrationType.S3) { continue; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java index 4c6af51cea..23c7a4246d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java @@ -4,6 +4,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import io.quarkus.arc.profile.IfBuildProfile; + import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -21,6 +23,7 @@ import stirling.software.proprietary.policy.store.PolicyStore; * stores. */ @ApplicationScoped +@IfBuildProfile("saas") @RequiredArgsConstructor public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java index 476c38dec4..86521b6ef5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java @@ -37,7 +37,8 @@ import tools.jackson.databind.ObjectMapper; @Slf4j @ApplicationScoped @RequiredArgsConstructor -@Transactional(readOnly = true) +// jakarta.transaction.Transactional has no readOnly hint; the reads are unchanged without it. +@Transactional public class S3ConnectionResolver { static final String CONNECTION_ID_OPTION = "connectionId"; @@ -57,7 +58,7 @@ public class S3ConnectionResolver { } IntegrationConfig connection = connections - .findById(connectionId) + .findByIdOptional(connectionId) .filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3) .filter(this::usableByCurrentUser) // Existence and access collapse into one error: a caller must not be able diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index c17df22d7f..d9aba93341 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -4,12 +4,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; +import io.quarkus.arc.profile.IfBuildProfile; +import io.quarkus.runtime.StartupEvent; +import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.event.TransactionPhase; +import jakarta.interceptor.Interceptor; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,6 +31,7 @@ import stirling.software.proprietary.security.service.TeamService; */ @Slf4j @ApplicationScoped +@IfBuildProfile("saas") @RequiredArgsConstructor public class DefaultClassificationPolicySeeder { @@ -40,7 +44,13 @@ public class DefaultClassificationPolicySeeder { // The default team is created during startup, before the entity event listener is guaranteed // wired, so ensure it once the context is fully ready (self-hosted first boot). - @EventListener(ApplicationReadyEvent.class) + // Priority above the default 2500 keeps this after InitialSecuritySetup creates that team, and + // @Transactional gives the Panache read the transaction a startup observer otherwise lacks. + @Transactional + void onStart(@Observes @Priority(Interceptor.Priority.APPLICATION + 1000) StartupEvent event) { + seedDefaultTeamOnStartup(); + } + public void seedDefaultTeamOnStartup() { teamRepository .findByName(TeamService.DEFAULT_TEAM_NAME) @@ -49,9 +59,9 @@ public class DefaultClassificationPolicySeeder { // Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own // transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a - // live transaction, which AFTER_COMMIT cannot offer. - @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) - public void onTeamCreated(TeamCreatedEvent event) { + // live transaction, which AFTER_SUCCESS cannot offer. + public void onTeamCreated( + @Observes(during = TransactionPhase.BEFORE_COMPLETION) TeamCreatedEvent event) { seedIfMissing(event.teamId(), event.teamName()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/FolderAccessDeniedExceptionMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/FolderAccessDeniedExceptionMapper.java new file mode 100644 index 0000000000..9e4d8d172c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/FolderAccessDeniedExceptionMapper.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.policy.source; + +import java.util.LinkedHashMap; +import java.util.Map; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import stirling.software.proprietary.policy.config.FolderAccessDeniedException; + +/** + * A folder source was rejected for pointing outside the allowed roots. Returns a 400 carrying + * {@link SourceController#FOLDER_ACCESS_DENIED_CODE} so the portal can offer a link to the Folder + * Access settings, while other guard rejections (SaaS mode, the protected config dir) fall through + * to the global handler as plain 400s the admin can't fix by editing the allowlist. + * + *

    Was an {@code @ExceptionHandler} on {@link SourceController}; a JAX-RS mapper is global, which + * is equivalent here because every other route reaching the folder guard catches {@link + * IllegalArgumentException} itself and never lets this type escape. + */ +@Provider +public class FolderAccessDeniedExceptionMapper + implements ExceptionMapper { + + private static final String PROBLEM_JSON = "application/problem+json"; + + @Override + public Response toResponse(FolderAccessDeniedException ex) { + // Same body Spring's ProblemDetail serialised (type/title default off the status), with the + // machine-readable code flattened alongside it as setProperty did. + Map problem = new LinkedHashMap<>(); + problem.put("type", "about:blank"); + problem.put("title", Response.Status.BAD_REQUEST.getReasonPhrase()); + problem.put("status", Response.Status.BAD_REQUEST.getStatusCode()); + problem.put("detail", ex.getMessage()); + problem.put("code", SourceController.FOLDER_ACCESS_DENIED_CODE); + return Response.status(Response.Status.BAD_REQUEST) + .type(PROBLEM_JSON) + .entity(problem) + .build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java index eb37ec09e0..889ad158db 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java @@ -5,7 +5,6 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; import java.util.function.IntSupplier; import java.util.function.Supplier; @@ -125,7 +124,7 @@ public class JpaSourceDocCounter implements SourceDocCounter { * nothing reported is lost. Daily is ample - the read queries already ignore older buckets, so * this is purely storage hygiene. */ - @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + @Scheduled(every = "24h") public void pruneOldBuckets() { countRepository.deleteOlderThan(SourceDocWindows.firstDayHour(currentHour())); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java index 31a89d0a2a..1980e2238c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java @@ -5,6 +5,7 @@ import java.util.Optional; import java.util.UUID; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -51,17 +52,22 @@ public class JpaSourceStore implements SourceStore { return stored; } + // Reads need a transaction too: the background folder-watch/webhook triggers resolve sources + // off any request context, where Panache would otherwise throw ContextNotActiveException. @Override + @Transactional public Optional get(String id) { - return repository.findById(id).flatMap(this::toSource); + return repository.findByIdOptional(id).flatMap(this::toSource); } @Override + @Transactional public List all() { return repository.findAll().stream().map(this::toSource).flatMap(Optional::stream).toList(); } @Override + @Transactional public List findByTeam(Long teamId) { return repository.findByTeam(teamId).stream() .map(this::toSource) @@ -70,6 +76,7 @@ public class JpaSourceStore implements SourceStore { } @Override + @Transactional public boolean delete(String id) { if (!repository.existsById(id)) { return false; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java index 73622738f0..28d5e09d6e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -4,25 +4,24 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - +import io.quarkus.arc.All; +import io.quarkus.arc.profile.IfBuildProfile; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.config.FolderAccessDeniedException; @@ -41,10 +40,10 @@ import stirling.software.proprietary.util.SecretMasker; * reports how many reference each one. Editing follows the same team-leader rule as policies, and * everything is scoped to the caller's team. */ -@RestController -@RequestMapping("/api/v1/sources") +@ApplicationScoped +@IfBuildProfile("saas") +@Path("/api/v1/sources") @Hidden -@RequiredArgsConstructor @Tag(name = "Sources", description = "Reusable policy input connections") public class SourceController { @@ -67,7 +66,32 @@ public class SourceController { private final ApplicationProperties applicationProperties; private final List inputSources; - @GetMapping + // Explicit constructor instead of Lombok so the List injection point can carry + // @All, which is how CDI collects every bean of a type the way Spring's List autowiring did. + @Inject + public SourceController( + SourceStore sourceStore, + SourceAccessGuard sourceAccessGuard, + SourceOverviewService overviewService, + PolicyStore policyStore, + PolicyAccessGuard policyAccessGuard, + PolicyManagementAuthority policyManagementAuthority, + PolicyTriggerManager policyTriggerManager, + ApplicationProperties applicationProperties, + @All List inputSources) { + this.sourceStore = sourceStore; + this.sourceAccessGuard = sourceAccessGuard; + this.overviewService = overviewService; + this.policyStore = policyStore; + this.policyAccessGuard = policyAccessGuard; + this.policyManagementAuthority = policyManagementAuthority; + this.policyTriggerManager = policyTriggerManager; + this.applicationProperties = applicationProperties; + this.inputSources = inputSources; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Sources overview", description = @@ -77,47 +101,53 @@ public class SourceController { return overviewService.overview(); } - @GetMapping("/{sourceId}") + @GET + @Path("/{sourceId}") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Get a source by id", description = "Secret-bearing options are returned as a redaction sentinel, never their" + " stored values; an edit that sends the sentinel back keeps them.") - public ResponseEntity get(@PathVariable String sourceId) { + public Response get(@PathParam("sourceId") String sourceId) { return sourceStore .get(sourceId) .filter(sourceAccessGuard::canAccess) .map(SourceController::withMaskedSecrets) - .map(ResponseEntity::ok) - .orElseGet(() -> ResponseEntity.notFound().build()); + .map(masked -> Response.ok(masked).build()) + .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build()); } - @GetMapping("/{sourceId}/document-counts") + @GET + @Path("/{sourceId}/document-counts") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Daily document counts for a source", description = "The trailing 30-day per-day document series (oldest first) for the source's" + " sparkline.") - public ResponseEntity> documentCounts(@PathVariable String sourceId) { + public Response documentCounts(@PathParam("sourceId") String sourceId) { // The editor is virtual: its series is tracked per team, not against a persisted source. if (EditorSource.ID.equals(sourceId)) { - return ResponseEntity.ok(overviewService.editorDailySeries()); + return Response.ok(overviewService.editorDailySeries()).build(); } return sourceStore .get(sourceId) .filter(sourceAccessGuard::canAccess) - .map(source -> ResponseEntity.ok(overviewService.dailySeries(source.id()))) - .orElseGet(() -> ResponseEntity.notFound().build()); + .map(source -> Response.ok(overviewService.dailySeries(source.id())).build()) + .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build()); } - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Create or update a source", description = "Stores an input connection (type + config). A blank id is assigned; owner and" + " team are stamped server-side. The config is validated against the" + " matching source type.") - public ResponseEntity save(@RequestBody Source source) { + public Response save(Source source) { requireSourceEditingAllowed(); requireNotEditor(source.id(), source.type()); boolean isCreate = source.id() == null || source.id().isBlank(); @@ -125,59 +155,44 @@ public class SourceController { try { validateConfig(owned); } catch (FolderAccessDeniedException e) { - // Surfaced with a machine-readable code by handleFolderAccessDenied so the portal can - // link to the Folder Access settings; don't flatten it into a plain 400 here. + // Surfaced with a machine-readable code by FolderAccessDeniedExceptionMapper so the + // portal can link to Folder Access settings; don't flatten it into a plain 400 here. throw e; } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); } Source saved = sourceStore.save(owned); // An edited folder source can change which directory needs watching, so re-sync trigger // registrations now instead of waiting for the next reconcile. policyTriggerManager.notifyPoliciesChanged(); - return ResponseEntity.ok(revealOnCreate(saved, isCreate)); + return Response.ok(revealOnCreate(saved, isCreate)).build(); } - @DeleteMapping("/{sourceId}") + @DELETE + @Path("/{sourceId}") @Operation( summary = "Delete a source", description = "Removes a source that no policy references. A source still in use returns 409" + " so the connection can't be pulled out from under a live policy.") - public ResponseEntity delete(@PathVariable String sourceId) { + public Response delete(@PathParam("sourceId") String sourceId) { requireSourceEditingAllowed(); requireNotEditor(sourceId, null); Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null); if (source == null) { - return ResponseEntity.notFound().build(); + return Response.status(Response.Status.NOT_FOUND).build(); } List referencing = referencingPolicyNames(sourceId); if (!referencing.isEmpty()) { - throw new ResponseStatusException( - HttpStatus.CONFLICT, + throw new WebApplicationException( "Source is referenced by " + referencing.size() + " policy(ies): " - + String.join(", ", referencing)); + + String.join(", ", referencing), + Response.Status.CONFLICT); } sourceStore.delete(sourceId); - return ResponseEntity.noContent().build(); - } - - /** - * A folder source was rejected for pointing outside the allowed roots. Return a 400 carrying - * {@link #FOLDER_ACCESS_DENIED_CODE} so the portal can offer a link to the Folder Access - * settings, while other guard rejections (SaaS mode, the protected config dir) fall through to - * the global handler as plain 400s the admin can't fix by editing the allowlist. - */ - @ExceptionHandler(FolderAccessDeniedException.class) - public ResponseEntity handleFolderAccessDenied(FolderAccessDeniedException ex) { - ProblemDetail problem = - ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); - problem.setProperty("code", FOLDER_ACCESS_DENIED_CODE); - return ResponseEntity.badRequest() - .contentType(MediaType.APPLICATION_PROBLEM_JSON) - .body(problem); + return Response.noContent().build(); } /** @@ -192,7 +207,8 @@ public class SourceController { Source existing = sourceStore.get(id).orElse(null); if (existing != null) { if (!sourceAccessGuard.canAccess(existing)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No source: " + id); + throw new WebApplicationException( + "No source: " + id, Response.Status.NOT_FOUND); } return withOwnerAndTeam(incoming, existing.owner(), existing.teamId()); } @@ -289,9 +305,9 @@ public class SourceController { return; } if (!policyManagementAuthority.canEditPolicies()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Sources may only be created or modified by a team leader"); + throw new WebApplicationException( + "Sources may only be created or modified by a team leader", + Response.Status.FORBIDDEN); } } @@ -301,9 +317,9 @@ public class SourceController { */ private static void requireNotEditor(String id, String type) { if (EditorSource.ID.equals(id) || EditorSource.TYPE.equals(type)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "The editor is a built-in source and cannot be created, edited, or deleted"); + throw new WebApplicationException( + "The editor is a built-in source and cannot be created, edited, or deleted", + Response.Status.BAD_REQUEST); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountEntity.java index 59fb5d954b..afc866ebde 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountEntity.java @@ -2,14 +2,11 @@ package stirling.software.proprietary.policy.source; import java.io.Serializable; -import org.springframework.data.domain.Persistable; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.IdClass; import jakarta.persistence.Table; -import jakarta.persistence.Transient; import lombok.Getter; import lombok.NoArgsConstructor; @@ -20,6 +17,9 @@ import lombok.Setter; * were fed, {@code docCount} the running total for that hour. Rolling-window totals are summed from * these buckets. {@code sourceId} is a plain value, not a foreign key, matching the rest of the * subsystem so it stays decoupled from the security entities. + * + *

    Buckets are inserted, never merged, so a concurrent insert surfaces as a constraint violation + * the counter retries as an increment rather than one run overwriting the other's tally. */ @Entity @Table(name = "policy_source_doc_counts") @@ -27,7 +27,7 @@ import lombok.Setter; @NoArgsConstructor @Getter @Setter -public class SourceDocCountEntity implements Serializable, Persistable { +public class SourceDocCountEntity implements Serializable { private static final long serialVersionUID = 1L; @@ -47,16 +47,4 @@ public class SourceDocCountEntity implements Serializable, Persistable { +public class SourceDocCountRepository + implements PanacheRepositoryBase { /** * Add to an existing bucket; returns the number of rows updated (0 when the bucket is new). * Transactional per call so {@code JpaSourceDocCounter.record} can run it (and the retry after * a concurrent insert) without an enclosing transaction. */ - @Modifying @Transactional - @Query( - "update SourceDocCountEntity e set e.docCount = e.docCount + :docs" - + " where e.sourceId = :sourceId and e.bucketHour = :bucketHour") - int increment( - @Param("sourceId") String sourceId, - @Param("bucketHour") long bucketHour, - @Param("docs") long docs); + public int increment(String sourceId, long bucketHour, long docs) { + return update( + "update SourceDocCountEntity e set e.docCount = e.docCount + :docs" + + " where e.sourceId = :sourceId and e.bucketHour = :bucketHour", + Parameters.with("sourceId", sourceId) + .and("bucketHour", bucketHour) + .and("docs", docs)); + } /** * Delete hourly buckets older than {@code floor} (hours-since-epoch). Nothing reads buckets @@ -36,22 +34,32 @@ public interface SourceDocCountRepository * {@code policy_source_doc_totals}, so retiring old buckets keeps the table bounded without * losing any reported figure. */ - @Modifying @Transactional - @Query("delete from SourceDocCountEntity e where e.bucketHour < :floor") - int deleteOlderThan(@Param("floor") long floor); + public int deleteOlderThan(long floor) { + return (int) + delete( + "delete from SourceDocCountEntity e where e.bucketHour < :floor", + Parameters.with("floor", floor)); + } /** * Document total per source restricted to buckets at or after {@code since} (the 24h window). */ - @Query( - "select new stirling.software.proprietary.policy.source.SourceDocSum(" - + "e.sourceId, sum(e.docCount))" - + " from SourceDocCountEntity e" - + " where e.sourceId in :ids and e.bucketHour >= :since" - + " group by e.sourceId") - List sumBySourceSince( - @Param("ids") Collection ids, @Param("since") long since); + public List sumBySourceSince(Collection ids, long since) { + // Constructor expression, so the rows come back as SourceDocSum: run it through the + // EntityManager to keep that typing. + return getEntityManager() + .createQuery( + "select new stirling.software.proprietary.policy.source.SourceDocSum(" + + "e.sourceId, sum(e.docCount))" + + " from SourceDocCountEntity e" + + " where e.sourceId in :ids and e.bucketHour >= :since" + + " group by e.sourceId", + SourceDocSum.class) + .setParameter("ids", ids) + .setParameter("since", since) + .getResultList(); + } /** * Per-source, per-day document totals for buckets at or after {@code since}, summed in the @@ -59,12 +67,29 @@ public interface SourceDocCountRepository * The day is {@code cast(floor(bucketHour / 24.0) as long)}: {@code 24.0} forces decimal * division and the cast pins the result to a whole day on every dialect. */ - @Query( - "select new stirling.software.proprietary.policy.source.SourceDayDocSum(" - + "e.sourceId, cast(floor(e.bucketHour / 24.0) as long), sum(e.docCount))" - + " from SourceDocCountEntity e" - + " where e.sourceId in :ids and e.bucketHour >= :since" - + " group by cast(floor(e.bucketHour / 24.0) as long), e.sourceId") - List dailyCountsSince( - @Param("ids") Collection ids, @Param("since") long since); + public List dailyCountsSince(Collection ids, long since) { + return getEntityManager() + .createQuery( + "select new stirling.software.proprietary.policy.source.SourceDayDocSum(" + + "e.sourceId, cast(floor(e.bucketHour / 24.0) as long)," + + " sum(e.docCount))" + + " from SourceDocCountEntity e" + + " where e.sourceId in :ids and e.bucketHour >= :since" + + " group by cast(floor(e.bucketHour / 24.0) as long), e.sourceId", + SourceDayDocSum.class) + .setParameter("ids", ids) + .setParameter("since", since) + .getResultList(); + } + + /** + * Spring Data's {@code saveAndFlush}. {@code persist} always INSERTs, which is what the entity + * asked for by reporting itself as new, and the flush surfaces a concurrent insert's constraint + * violation here so the caller can retry it as an increment. + */ + @Transactional + public SourceDocCountEntity saveAndFlush(SourceDocCountEntity row) { + persistAndFlush(row); + return row; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalEntity.java index afff11d43a..b57cd5e73c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalEntity.java @@ -2,13 +2,10 @@ package stirling.software.proprietary.policy.source; import java.io.Serializable; -import org.springframework.data.domain.Persistable; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; -import jakarta.persistence.Transient; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,17 +16,16 @@ import lombok.Setter; * row instead of scanning the source's whole hourly-bucket history, and so {@link * SourceDocCountEntity} buckets can be pruned to the rolling 30-day window without losing it. * - *

    Like {@link SourceDocCountEntity}, implements {@link Persistable} reporting {@code isNew() == - * true} so a new source's first {@code save} {@code persist}s (a raw INSERT) and a concurrent - * insert surfaces as a constraint violation the counter retries as an increment, rather than {@code - * merge} silently overwriting it. + *

    Like {@link SourceDocCountEntity}, a new source's first total row is inserted rather than + * merged, so a concurrent insert surfaces as a constraint violation the counter retries as an + * increment instead of silently overwriting it. */ @Entity @Table(name = "policy_source_doc_totals") @NoArgsConstructor @Getter @Setter -public class SourceDocTotalEntity implements Serializable, Persistable { +public class SourceDocTotalEntity implements Serializable { private static final long serialVersionUID = 1L; @@ -44,16 +40,4 @@ public class SourceDocTotalEntity implements Serializable, Persistable { this.sourceId = sourceId; this.docTotal = docTotal; } - - @Override - @Transient - public String getId() { - return sourceId; - } - - @Override - @Transient - public boolean isNew() { - return true; - } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalRepository.java index 49442a8c8e..f2e2fb6ef0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalRepository.java @@ -3,33 +3,49 @@ package stirling.software.proprietary.policy.source; import java.util.Collection; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; @ApplicationScoped -public interface SourceDocTotalRepository extends JpaRepository { +public class SourceDocTotalRepository + implements PanacheRepositoryBase { /** * Add to a source's lifetime total; returns the number of rows updated (0 when the source has * no total row yet). Transactional per call so {@code JpaSourceDocCounter.record} can run it * (and the retry after a concurrent insert) without an enclosing transaction. */ - @Modifying @Transactional - @Query( - "update SourceDocTotalEntity e set e.docTotal = e.docTotal + :docs" - + " where e.sourceId = :sourceId") - int increment(@Param("sourceId") String sourceId, @Param("docs") long docs); + public int increment(String sourceId, long docs) { + return update( + "update SourceDocTotalEntity e set e.docTotal = e.docTotal + :docs" + + " where e.sourceId = :sourceId", + Parameters.with("sourceId", sourceId).and("docs", docs)); + } /** Lifetime totals for the given sources, as {@code (sourceId, total)} rows. */ - @Query( - "select new stirling.software.proprietary.policy.source.SourceDocSum(" - + "e.sourceId, e.docTotal)" - + " from SourceDocTotalEntity e where e.sourceId in :ids") - List totalsFor(@Param("ids") Collection ids); + public List totalsFor(Collection ids) { + // Projection into a record, so it goes through the EntityManager rather than Panache. + return getEntityManager() + .createQuery( + "select new stirling.software.proprietary.policy.source.SourceDocSum(" + + "e.sourceId, e.docTotal)" + + " from SourceDocTotalEntity e where e.sourceId in :ids", + SourceDocSum.class) + .setParameter("ids", ids) + .getResultList(); + } + + /** + * Spring Data's {@code saveAndFlush} on an entity that always reported {@code isNew()}: a raw + * INSERT, flushed so a concurrent insert surfaces here as a constraint violation to retry. + */ + @Transactional + public SourceDocTotalEntity saveAndFlush(SourceDocTotalEntity entity) { + persistAndFlush(entity); + return entity; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index 3c8347ee05..9c7afba4aa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -8,6 +8,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import io.quarkus.arc.profile.IfBuildProfile; + import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -24,6 +26,7 @@ import stirling.software.proprietary.util.SecretMasker; * always consistent with the live policy set. */ @ApplicationScoped +@IfBuildProfile("saas") @RequiredArgsConstructor public class SourceOverviewService { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java index 3e30399c15..915a9006cf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java @@ -2,22 +2,42 @@ package stirling.software.proprietary.policy.source; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; @ApplicationScoped -public interface SourceRepository extends JpaRepository { +public class SourceRepository implements PanacheRepositoryBase { /** * Sources belonging to a team, loaded without scanning every team's rows. A {@code null} teamId * matches the rows with no team (login-disabled / pre-team data), mirroring the in-memory team * filter rather than the empty result a plain {@code = null} would give. */ - @Query( - "select s from SourceEntity s where (:teamId is null and s.teamId is null) or" - + " s.teamId = :teamId") - List findByTeam(@Param("teamId") Long teamId); + public List findByTeam(Long teamId) { + return list( + "select s from SourceEntity s where (:teamId is null and s.teamId is null) or" + + " s.teamId = :teamId", + Parameters.with("teamId", teamId)); + } + + /** + * Spring Data {@code save(entity)}: transactional per call, inserting a new row and merging a + * detached one, so re-saving a source with a known id updates it in place. + */ + @Transactional + public SourceEntity save(SourceEntity entity) { + if (entity.getId() == null || getEntityManager().contains(entity)) { + persist(entity); + return entity; + } + return getEntityManager().merge(entity); + } + + /** Spring Data {@code existsById(id)} -> Panache count by id. */ + public boolean existsById(String id) { + return count("id", id) > 0; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 1be4ef31d9..7ab79b1864 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -11,6 +11,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyBinding; @@ -24,6 +25,7 @@ import tools.jackson.databind.node.ObjectNode; * Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via * {@link PolicyEntity}, with scalar columns kept in sync for querying. */ +@Slf4j @ApplicationScoped @RequiredArgsConstructor @IfBuildProfile("saas") @@ -61,11 +63,13 @@ public class JpaPolicyStore implements PolicyStore { // team's queue (max + 1), so setting up a policy adds it last by default. entity.setSortOrder( repository - .findById(id) + .findByIdOptional(id) .map(PolicyEntity::getSortOrder) .orElseGet(() -> nextSortOrder(stored.teamId()))); entity.setPolicyJson(objectMapper.writeValueAsString(stored)); - repository.persist(entity); + // The id is always assigned, so this is Spring Data's save: merge, which updates an + // existing row instead of failing the insert a plain persist() would attempt. + repository.getEntityManager().merge(entity); return stored; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index 73edc1a86c..c1f6d8f2c0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -69,10 +69,12 @@ public class FolderWatchTrigger implements PolicyTrigger { PolicyStore policyStore, PolicyRunner policyRunner, @All List inputSources, + SourceStore sourceStore, ApplicationProperties applicationProperties) { this.policyStore = policyStore; this.policyRunner = policyRunner; this.inputSources = inputSources; + this.sourceStore = sourceStore; this.applicationProperties = applicationProperties; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java index a0555c44ef..e13032b58d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java @@ -5,6 +5,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import io.quarkus.arc.profile.IfBuildProfile; + import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -23,6 +25,7 @@ import stirling.software.proprietary.policy.webhook.WebhookConfig; @Slf4j @ApplicationScoped +@IfBuildProfile("saas") @RequiredArgsConstructor public class WebhookTrigger implements PolicyTrigger { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java index 509c6c7fd3..0230dcbc3a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java @@ -4,21 +4,22 @@ import java.io.IOException; import java.io.InputStream; import java.util.Arrays; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - +import io.quarkus.arc.profile.IfBuildProfile; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,8 +30,9 @@ import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.WebhookTrigger; @Slf4j -@RestController -@RequestMapping("/api/v1/webhooks") +@ApplicationScoped +@IfBuildProfile("saas") +@Path("/api/v1/webhooks") @Hidden @RequiredArgsConstructor @Tag(name = "Webhooks", description = "Inbound webhook source receiver") @@ -45,37 +47,41 @@ public class WebhookReceiverController { private final WebhookTrigger webhookTrigger; private final ApplicationProperties applicationProperties; - @PostMapping("/{webhookId}") + @POST + @Path("/{webhookId}") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Deliver a document to a webhook source", description = "The body is the raw document; sign it with the source's secret and present" + " 'sha256=' in the X-Stirling-Signature header. Returns 202 once" + " the document is spooled for the referencing policies.") - public ResponseEntity receive( - @PathVariable String webhookId, - @RequestHeader(value = SIGNATURE_HEADER, required = false) String signature, - @RequestHeader(value = FILENAME_HEADER, required = false) String filename, - HttpServletRequest request) { + public Response receive( + @PathParam("webhookId") String webhookId, + @HeaderParam(SIGNATURE_HEADER) String signature, + @HeaderParam(FILENAME_HEADER) String filename, + @Context HttpHeaders headers, + InputStream requestBody) { if (!WebhookIds.isValidId(webhookId)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook"); + throw new WebApplicationException("No such webhook", Response.Status.NOT_FOUND); } Source source = findWebhookSource(webhookId); if (source == null) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook"); + throw new WebApplicationException("No such webhook", Response.Status.NOT_FOUND); } WebhookConfig config = WebhookConfig.from(source.options()); - byte[] body = readBoundedBody(request); + byte[] body = readBoundedBody(headers, requestBody); if (!WebhookSignatures.verify(config.signingSecret(), body, signature)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature"); + throw new WebApplicationException("Invalid signature", Response.Status.UNAUTHORIZED); } if (!source.enabled()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Webhook source is paused; deliveries are not accepted"); + throw new WebApplicationException( + "Webhook source is paused; deliveries are not accepted", + Response.Status.FORBIDDEN); } if (body.length == 0) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Empty request body"); + throw new WebApplicationException("Empty request body", Response.Status.BAD_REQUEST); } String storedName = stageToSpool(webhookId, filename, body); @@ -86,9 +92,8 @@ public class WebhookReceiverController { storedName, body.length, webhookId); - return ResponseEntity.accepted() - .contentType(MediaType.APPLICATION_JSON) - .body(new WebhookDeliveryResponse(true, storedName, body.length)); + return Response.accepted(new WebhookDeliveryResponse(true, storedName, body.length)) + .build(); } private Source findWebhookSource(String webhookId) { @@ -110,41 +115,55 @@ public class WebhookReceiverController { spool.store(webhookId, filename, body).getFileName().toString()); } catch (IOException e) { log.error("Could not spool webhook delivery for {}: {}", webhookId, e.getMessage()); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery"); + throw new WebApplicationException( + "Could not store delivery", Response.Status.INTERNAL_SERVER_ERROR); } } - private byte[] readBoundedBody(HttpServletRequest request) { + private byte[] readBoundedBody(HttpHeaders headers, InputStream requestBody) { long maxBytes = applicationProperties.getPolicies().getWebhookMaxBytes(); - long declared = request.getContentLengthLong(); + long declared = declaredLength(headers); if (declared < 0) { - throw new ResponseStatusException( - HttpStatus.LENGTH_REQUIRED, "A Content-Length header is required"); + throw new WebApplicationException( + "A Content-Length header is required", Response.Status.LENGTH_REQUIRED); } if (declared > maxBytes) { - throw new ResponseStatusException( - HttpStatus.PAYLOAD_TOO_LARGE, - "Delivery exceeds the " + maxBytes + "-byte limit"); + throw new WebApplicationException( + "Delivery exceeds the " + maxBytes + "-byte limit", + Response.Status.REQUEST_ENTITY_TOO_LARGE); } byte[] body = new byte[(int) declared]; int total = 0; - try (InputStream in = request.getInputStream()) { + // A body-less POST arrives as a null entity; treat it as empty rather than NPE-ing. + try (InputStream in = requestBody == null ? InputStream.nullInputStream() : requestBody) { int read; while (total < body.length && (read = in.read(body, total, body.length - total)) != -1) { total += read; } if (total == body.length && in.read() != -1) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Body exceeds the declared Content-Length"); + throw new WebApplicationException( + "Body exceeds the declared Content-Length", Response.Status.BAD_REQUEST); } } catch (IOException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Could not read request body"); + throw new WebApplicationException( + "Could not read request body", Response.Status.BAD_REQUEST); } return total == body.length ? body : Arrays.copyOf(body, total); } + // Mirrors the servlet getContentLengthLong() this used to read: -1 when absent or unparseable. + private static long declaredLength(HttpHeaders headers) { + String declared = headers.getHeaderString(HttpHeaders.CONTENT_LENGTH); + if (declared == null) { + return -1; + } + try { + return Long.parseLong(declared.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + public record WebhookDeliveryResponse(boolean accepted, String filename, int bytes) {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java index 68e33c19e5..6d9b2ed615 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java @@ -4,19 +4,20 @@ import java.io.IOException; import java.util.Map; import java.util.Set; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,8 +29,8 @@ import stirling.software.common.service.LoginAgreementService; * (customFiles/disclaimer/<locale>.md). The enable/visibility flags are managed through the * normal admin settings endpoints; only the live-edited text is handled here. */ -@RestController -@RequestMapping("/api/v1/admin/login-agreement") +@ApplicationScoped +@Path("/api/v1/admin/login-agreement") @RolesAllowed("ADMIN") @RequiredArgsConstructor @Tag(name = "Admin Settings", description = "Login agreement text management") @@ -39,35 +40,39 @@ public class AdminLoginAgreementController { private final LoginAgreementService loginAgreementService; - @GetMapping + @GET + @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "List locales that currently have login agreement text") public Set listLocales() { return loginAgreementService.listLocalesWithContent(); } - @GetMapping("/{locale}") + @GET + @Path("/{locale}") + @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Read the login agreement markdown for a locale") - public ResponseEntity> read(@PathVariable String locale) { + public Response read(@PathParam("locale") String locale) { String content = loginAgreementService.readRawForLocale(locale); if (content == null) { - return ResponseEntity.badRequest().build(); + return Response.status(Response.Status.BAD_REQUEST).build(); } - return ResponseEntity.ok(Map.of("locale", locale, "content", content)); + return Response.ok(Map.of("locale", locale, "content", content)).build(); } - @PutMapping("/{locale}") + @PUT + @Path("/{locale}") + @Consumes(MediaType.APPLICATION_JSON) @Operation(summary = "Write the login agreement markdown for a locale (blank clears it)") - public ResponseEntity write( - @PathVariable String locale, @RequestBody DisclaimerContentRequest request) { + public Response write(@PathParam("locale") String locale, DisclaimerContentRequest request) { try { loginAgreementService.writeForLocale( locale, request == null ? null : request.content()); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().build(); + return Response.status(Response.Status.BAD_REQUEST).build(); } catch (IOException e) { log.error("Failed writing login agreement for locale {}", locale, e); - return ResponseEntity.internalServerError().build(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 61f6ad9f31..657e0ecb70 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -192,7 +192,7 @@ public class AdminSettingsController { return isSensitiveFieldWithPath(leaf, key); }); if (settings.isEmpty()) { - return ResponseEntity.ok(Map.of("message", "No changed settings to update.")); + return Response.ok(Map.of("message", "No changed settings to update.")).build(); } // Validate all settings first before applying any changes diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index 088474c6ac..1421b03f13 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -46,6 +46,7 @@ public class TeamController { @POST @Path("/create") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Transactional public Response createTeam(@QueryParam("name") String name) { if (teamRepository.existsByNameIgnoreCase(name)) { return Response.status(Response.Status.CONFLICT) @@ -62,6 +63,7 @@ public class TeamController { @POST @Path("/rename") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Transactional public Response renameTeam( @QueryParam("teamId") Long teamId, @QueryParam("newName") String newName) { Optional existing = teamRepository.findByIdOptional(teamId); @@ -122,11 +124,12 @@ public class TeamController { } if (integrationConfigRepository.existsByOwnerTeam_Id(teamId)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body( + return Response.status(Response.Status.CONFLICT) + .entity( Map.of( "error", - "Team still owns integration configurations. Delete or reassign them first.")); + "Team still owns integration configurations. Delete or reassign them first.")) + .build(); } // Team grants and membership rows would dangle once the team row is gone diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index ccdf5431ed..84062211b8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -37,6 +37,7 @@ import stirling.software.common.security.UsernameNotFoundException; import stirling.software.common.security.UsernamePasswordAuthenticationToken; import stirling.software.proprietary.security.JwtAuthenticationEntryPoint; import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index e22ea21234..89e2cc79a5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -6,6 +6,8 @@ import java.io.IOException; import java.util.List; import java.util.Optional; +import org.slf4j.MDC; + import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.inject.Named; @@ -29,6 +31,7 @@ import stirling.software.common.security.OAuth2User; import stirling.software.common.security.SecurityContextHolder; import stirling.software.common.security.SessionInformation; import stirling.software.common.security.UserDetails; +import stirling.software.common.security.UsernamePasswordAuthenticationToken; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; @@ -72,13 +75,6 @@ public class UserAuthenticationFilter implements Filter { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; - // Start each request clean so a pooled thread can't inherit a prior request's key label - - // but keep a label an upstream filter (JwtAuthenticationFilter) already set for a request - // it API-key-authenticated. - if (SecurityContextHolder.getContext().getAuthentication() == null) { - MDC.remove(API_KEY_LABEL_MDC); - } - // Spring's OncePerRequestFilter#shouldNotFilter behavior: skip the filter body for static // resources, SPA routes and public API endpoints. if (shouldNotFilter(request)) { @@ -86,6 +82,13 @@ public class UserAuthenticationFilter implements Filter { return; } + // Start each request clean so a pooled thread can't inherit a prior request's key label - + // but keep a label an upstream filter (JwtAuthenticationFilter) already set for a request + // it API-key-authenticated. + if (SecurityContextHolder.getContext().getAuthentication() == null) { + MDC.remove(API_KEY_LABEL_MDC); + } + if (!loginEnabledValue) { // If login is not enabled, just pass all requests without authentication filterChain.doFilter(request, response); @@ -127,20 +130,8 @@ public class UserAuthenticationFilter implements Filter { // UsernamePasswordAuthenticationToken so the Authentication variable stays // typed. authentication = - new stirling.software.common.security - .UsernamePasswordAuthenticationToken( - user, - apiKey, - user.getAuthorities().stream() - .map( - a -> - new stirling.software.common.security - .SimpleGrantedAuthority( - a.getAuthority())) - .collect(java.util.stream.Collectors.toSet())); - if (resolved.get().auditLabel() != null) { - MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel()); - } + new UsernamePasswordAuthenticationToken( + user, apiKey, resolved.get().authorities()); SecurityContextHolder.getContext().setAuthentication(authentication); if (resolved.get().auditLabel() != null) { MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel()); @@ -308,7 +299,7 @@ public class UserAuthenticationFilter implements Filter { // Was Spring's OncePerRequestFilter#shouldNotFilter; now called explicitly at the top of // doFilter. - private boolean shouldNotFilter(HttpServletRequest request) { + protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); String contextPath = request.getContextPath(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java index 24e69a7549..f581efb468 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java @@ -70,17 +70,13 @@ public class UserBasedRateLimitingFilter implements jakarta.servlet.Filter { // extra keys can't multiply the daily limit. Fall back to the raw key / IP only when the // request is unauthenticated. String identifier = null; - // Check for API key in the request headers - String apiKey = request.getHeader("X-API-KEY"); - if (apiKey != null && !apiKey.trim().isEmpty()) { - identifier = // Prefix to distinguish between API keys and usernames - "API_KEY_" + apiKey; - } else if (securityIdentity != null && !securityIdentity.isAnonymous()) { + if (securityIdentity != null && !securityIdentity.isAnonymous()) { identifier = securityIdentity.getPrincipal().getName(); } if (identifier == null) { String apiKey = request.getHeader("X-API-KEY"); if (apiKey != null && !apiKey.trim().isEmpty()) { + // Prefix to distinguish between API keys and usernames identifier = "API_KEY_" + apiKey; } else { identifier = request.getRemoteAddr(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java index abf95836e4..ce457f1a61 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java @@ -3,54 +3,129 @@ package stirling.software.proprietary.security.repository; import java.util.Collection; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import stirling.software.proprietary.security.model.ApiKeyDailyUsage; import stirling.software.proprietary.security.model.ApiKeyDailyUsageId; +/** + * Quarkus Panache repository for {@link ApiKeyDailyUsage}. + * + *

    Migrated from a Spring Data {@code JpaRepository}; every {@code @Query} JPQL string is + * preserved verbatim. Panache has no interface projections, so the two batched queries run through + * the {@code EntityManager} and their rows are mapped onto {@link ApiKeyUsageSum} by hand. + */ @ApplicationScoped -public interface ApiKeyDailyUsageRepository - extends JpaRepository { +public class ApiKeyDailyUsageRepository + implements PanacheRepositoryBase { /** Atomically bump today's tally; returns 0 when no row exists yet (caller then inserts). */ - @Modifying - @Query( - "UPDATE ApiKeyDailyUsage u SET u.count = u.count + 1 " - + "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay") - int incrementIfPresent(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay); + @Transactional + public int incrementIfPresent(Long apiKeyId, long epochDay) { + return update( + "UPDATE ApiKeyDailyUsage u SET u.count = u.count + 1 " + + "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay", + Parameters.with("apiKeyId", apiKeyId).and("epochDay", epochDay)); + } - @Query( - "SELECT COALESCE(SUM(u.count), 0) FROM ApiKeyDailyUsage u " - + "WHERE u.apiKeyId = :apiKeyId AND u.epochDay >= :fromDayInclusive") - long sumSince( - @Param("apiKeyId") Long apiKeyId, @Param("fromDayInclusive") long fromDayInclusive); + public long sumSince(Long apiKeyId, long fromDayInclusive) { + // Read as Object: COALESCE over SUM boxes as a dialect-dependent integral type. + Object total = + getEntityManager() + .createQuery( + "SELECT COALESCE(SUM(u.count), 0) FROM ApiKeyDailyUsage u" + + " WHERE u.apiKeyId = :apiKeyId" + + " AND u.epochDay >= :fromDayInclusive") + .setParameter("apiKeyId", apiKeyId) + .setParameter("fromDayInclusive", fromDayInclusive) + .getSingleResult(); + return ((Number) total).longValue(); + } - @Query( - "SELECT u.count FROM ApiKeyDailyUsage u " - + "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay") - Long countForDay(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay); + /** Null when the key has no row for that day, as the Spring Data single-result query gave. */ + public Long countForDay(Long apiKeyId, long epochDay) { + return getEntityManager() + .createQuery( + "SELECT u.count FROM ApiKeyDailyUsage u " + + "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay", + Long.class) + .setParameter("apiKeyId", apiKeyId) + .setParameter("epochDay", epochDay) + .getResultStream() + .findFirst() + .orElse(null); + } /** Batched today-count for many keys in one query (avoids N+1 when listing keys). */ - @Query( - "SELECT u.apiKeyId AS apiKeyId, u.count AS total FROM ApiKeyDailyUsage u " - + "WHERE u.apiKeyId IN :ids AND u.epochDay = :epochDay") - List countForDayByIds( - @Param("ids") Collection ids, @Param("epochDay") long epochDay); + public List countForDayByIds(Collection ids, long epochDay) { + List rows = + getEntityManager() + .createQuery( + "SELECT u.apiKeyId AS apiKeyId, u.count AS total" + + " FROM ApiKeyDailyUsage u WHERE u.apiKeyId IN :ids" + + " AND u.epochDay = :epochDay", + Object[].class) + .setParameter("ids", ids) + .setParameter("epochDay", epochDay) + .getResultList(); + return toSums(rows); + } /** Batched trailing-window sum for many keys in one query. */ - @Query( - "SELECT u.apiKeyId AS apiKeyId, SUM(u.count) AS total FROM ApiKeyDailyUsage u " - + "WHERE u.apiKeyId IN :ids AND u.epochDay >= :fromDayInclusive " - + "GROUP BY u.apiKeyId") - List sumSinceByIds( - @Param("ids") Collection ids, @Param("fromDayInclusive") long fromDayInclusive); + public List sumSinceByIds(Collection ids, long fromDayInclusive) { + List rows = + getEntityManager() + .createQuery( + "SELECT u.apiKeyId AS apiKeyId, SUM(u.count) AS total" + + " FROM ApiKeyDailyUsage u WHERE u.apiKeyId IN :ids" + + " AND u.epochDay >= :fromDayInclusive" + + " GROUP BY u.apiKeyId", + Object[].class) + .setParameter("ids", ids) + .setParameter("fromDayInclusive", fromDayInclusive) + .getResultList(); + return toSums(rows); + } - void deleteByApiKeyId(Long apiKeyId); + @Transactional + public void deleteByApiKeyId(Long apiKeyId) { + delete("apiKeyId = ?1", apiKeyId); + } - List findByApiKeyId(Long apiKeyId); + public List findByApiKeyId(Long apiKeyId) { + return list("apiKeyId = ?1", apiKeyId); + } + + /** Spring Data's {@code saveAndFlush}: flushes so a constraint violation surfaces here. */ + public ApiKeyDailyUsage saveAndFlush(ApiKeyDailyUsage row) { + persistAndFlush(row); + return row; + } + + private static List toSums(List rows) { + return rows.stream() + .map(row -> new UsageSum(asLong(row[0]), asLong(row[1]))) + .toList(); + } + + private static Long asLong(Object value) { + return value == null ? null : ((Number) value).longValue(); + } + + private record UsageSum(Long apiKeyId, Long total) implements ApiKeyUsageSum { + + @Override + public Long getApiKeyId() { + return apiKeyId; + } + + @Override + public Long getTotal() { + return total; + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java index bae7d23f4e..0c7e10efd1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java @@ -3,18 +3,43 @@ package stirling.software.proprietary.security.repository; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Sort; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import stirling.software.proprietary.security.model.ApiKey; @ApplicationScoped -public interface ApiKeyRepository extends JpaRepository { +public class ApiKeyRepository implements PanacheRepositoryBase { - Optional findByKeyHash(String keyHash); + public Optional findByKeyHash(String keyHash) { + return find("keyHash", keyHash).firstResultOptional(); + } - boolean existsByKeyHash(String keyHash); + public boolean existsByKeyHash(String keyHash) { + return count("keyHash", keyHash) > 0; + } - List findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId); + public List findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId) { + return list("ownerUserId", Sort.descending("createdAt"), ownerUserId); + } + + /** Spring Data {@code save}: insert a new row, merge an already-identified one. */ + @Transactional + public ApiKey save(ApiKey key) { + if (key.getId() == null) { + persist(key); + return key; + } + return getEntityManager().merge(key); + } + + /** Spring Data {@code saveAndFlush}: flushes so a unique-key clash surfaces here. */ + @Transactional + public ApiKey saveAndFlush(ApiKey key) { + persistAndFlush(key); + return key; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/JwtSigningKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/JwtSigningKeyRepository.java index 8753f19925..e41ede62d1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/JwtSigningKeyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/JwtSigningKeyRepository.java @@ -4,24 +4,38 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Sort; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import stirling.software.proprietary.security.model.JwtSigningKeyEntity; /** Shared-DB store of JWT signing keys - the source of truth every cluster node reads from. */ @ApplicationScoped -public interface JwtSigningKeyRepository extends JpaRepository { +public class JwtSigningKeyRepository implements PanacheRepositoryBase { /** Newest first, so the most recently created key is the active signing key. */ - List findAllByOrderByCreatedAtDesc(); + public List findAllByOrderByCreatedAtDesc() { + return listAll(Sort.descending("createdAt")); + } /** * The current active signing key: the single newest row. Used for cheap cluster convergence. */ - Optional findFirstByOrderByCreatedAtDesc(); + public Optional findFirstByOrderByCreatedAtDesc() { + return findAll(Sort.descending("createdAt")).firstResultOptional(); + } /** Keys created before the cutoff, eligible for rotation cleanup. */ - List findByCreatedAtBefore(LocalDateTime cutoff); + public List findByCreatedAtBefore(LocalDateTime cutoff) { + return list("createdAt < ?1", cutoff); + } + + /** Spring Data {@code save}: keyId is assigned, so merge inserts or updates as it did. */ + @Transactional + public JwtSigningKeyEntity save(JwtSigningKeyEntity entity) { + return getEntityManager().merge(entity); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java index a4f36d9e5c..9439877bcf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java @@ -58,6 +58,16 @@ public class TeamMembershipRepository implements PanacheRepositoryBase getSessionIndexes() { return this.sessionIndexes; } - @Override - public String getResponseValue() { - return this.responseValue; - } - - @Override @SuppressWarnings("unchecked") public List getAttribute(String name) { List values = this.attributes.get(name); return values != null ? (List) values : null; } - @Override @SuppressWarnings("unchecked") public A getFirstAttribute(String name) { List values = this.attributes.get(name); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java index 5a990083b4..ecf8f35039 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.security.service; import java.time.Instant; import java.util.Collection; +import java.util.List; import java.util.Optional; import jakarta.enterprise.context.ApplicationScoped; @@ -10,6 +11,7 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import stirling.software.common.security.GrantedAuthority; +import stirling.software.common.security.SimpleGrantedAuthority; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.ApiKey; import stirling.software.proprietary.security.model.User; @@ -58,20 +60,30 @@ public class ApiKeyAuthenticationService { if (!key.isActive()) { return Optional.empty(); } - User owner = userRepository.findById(key.getOwnerUserId()).orElse(null); + User owner = userRepository.findByIdOptional(key.getOwnerUserId()).orElse(null); if (owner == null || !owner.isEnabled()) { return Optional.empty(); } usageRecorder.record(key.getId()); return Optional.of( - new ApiKeyAuthentication(owner, auditLabel(key), owner.getAuthorities())); + new ApiKeyAuthentication(owner, auditLabel(key), authoritiesOf(owner))); } // Legacy single per-user key: keep working, always a personal key for its user. return userRepository .findByApiKey(rawKey) .filter(User::isEnabled) - .map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities())); + .map(user -> new ApiKeyAuthentication(user, null, authoritiesOf(user))); + } + + // Authority is a JPA entity, not a GrantedAuthority shim; adapt each role string. + private static List authoritiesOf(User user) { + return user.getAuthorities().stream() + .map( + authority -> + (GrantedAuthority) + new SimpleGrantedAuthority(authority.getAuthority())) + .toList(); } /** "Production ingest (sk_a1b2c3d4)" - shown against API-sourced docs in the processor feed. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java index 1e3da24c8e..9490fc506b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java @@ -1,7 +1,5 @@ package stirling.software.proprietary.security.service; -import org.springframework.transaction.annotation.Propagation; - import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; @@ -16,8 +14,7 @@ import stirling.software.proprietary.security.repository.ApiKeyRepository; * caller's listing transaction: when two concurrent first-loads race to insert the same hash, the * loser's unique-key clash rolls back only this insert instead of poisoning the caller's * transaction (on Postgres a failed statement aborts the whole transaction). The {@code - * DataIntegrityViolationException} is left to propagate so the caller can treat it as "already - * migrated". + * PersistenceException} is left to propagate so the caller can treat it as "already migrated". */ @ApplicationScoped @RequiredArgsConstructor @@ -25,7 +22,7 @@ class ApiKeyLegacyMigrator { private final ApiKeyRepository apiKeyRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional(Transactional.TxType.REQUIRES_NEW) public void insertMigratedKey(ApiKey key) { apiKeyRepository.saveAndFlush(key); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java index 37877156ef..968ce41d43 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java @@ -7,12 +7,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; - import jakarta.enterprise.context.ApplicationScoped; import jakarta.persistence.PersistenceException; import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -107,23 +106,23 @@ public class ApiKeyManagementService { User caller = requireCaller(); String name = request == null ? null : request.name(); if (name == null || name.isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Key name is required"); + throw new WebApplicationException("Key name is required", Response.Status.BAD_REQUEST); } if (name.trim().length() > MAX_NAME_LENGTH) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Key name must be " + MAX_NAME_LENGTH + " characters or fewer"); + throw new WebApplicationException( + "Key name must be " + MAX_NAME_LENGTH + " characters or fewer", + Response.Status.BAD_REQUEST); } long activeOwned = apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()).stream() .filter(ApiKey::isActive) .count(); if (activeOwned >= MAX_ACTIVE_KEYS_PER_USER) { - throw new ResponseStatusException( - HttpStatus.TOO_MANY_REQUESTS, + throw new WebApplicationException( "You have reached the maximum of " + MAX_ACTIVE_KEYS_PER_USER - + " active API keys; revoke one before creating another"); + + " active API keys; revoke one before creating another", + Response.Status.TOO_MANY_REQUESTS); } String rawKey = ApiKeyHasher.generateRawKey(); @@ -147,12 +146,14 @@ public class ApiKeyManagementService { User caller = requireCaller(); ApiKey key = apiKeyRepository - .findById(id) + .findByIdOptional(id) .orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No key")); + () -> + new WebApplicationException( + "No key", Response.Status.NOT_FOUND)); if (!key.getOwnerUserId().equals(caller.getId())) { // Not-found rather than forbidden so a caller can't probe other users' key ids. - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key"); + throw new WebApplicationException("No key", Response.Status.NOT_FOUND); } key.setEnabled(false); key.setRevokedAt(Instant.now()); @@ -193,14 +194,14 @@ public class ApiKeyManagementService { */ private void clearLegacyColumnIfMatches(ApiKey key) { userRepository - .findById(key.getOwnerUserId()) + .findByIdOptional(key.getOwnerUserId()) .ifPresent( owner -> { String legacy = owner.getApiKey(); if (legacy != null && ApiKeyHasher.hash(legacy).equals(key.getKeyHash())) { owner.setApiKey(null); - userRepository.save(owner); + userRepository.persist(owner); } }); } @@ -226,11 +227,13 @@ public class ApiKeyManagementService { private User requireCaller() { String username = userService.getCurrentUsername(); if (username == null || username.isBlank() || "anonymousUser".equalsIgnoreCase(username)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + throw new WebApplicationException("Not authenticated", Response.Status.UNAUTHORIZED); } return userService .findByUsernameIgnoreCase(username) .orElseThrow( - () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown user")); + () -> + new WebApplicationException( + "Unknown user", Response.Status.UNAUTHORIZED)); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java index f7b0dd3f6b..254988bed9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java @@ -2,33 +2,44 @@ package stirling.software.proprietary.security.service; import java.time.Instant; import java.time.ZoneOffset; - -import org.springframework.scheduling.annotation.Async; +import java.util.concurrent.Executor; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * Records per-key usage off the request thread. Kept a separate bean so the {@code @Async} proxy is - * honoured (a self-invocation from the resolver would run inline). Best-effort: never fails a - * request. The actual writes go through {@link ApiKeyUsageWriter} so each step commits in its own - * transaction and a first-write race can't drop a count. + * Records per-key usage off the request thread, dispatching onto the MDC-propagating {@code + * auditExecutor}. Best-effort: never fails a request. The actual writes go through {@link + * ApiKeyUsageWriter} so each step commits in its own transaction and a first-write race can't drop + * a count. */ @Slf4j @ApplicationScoped -@RequiredArgsConstructor public class ApiKeyUsageRecorder { private final ApiKeyUsageWriter writer; + private final Executor auditExecutor; + + @Inject + public ApiKeyUsageRecorder( + ApiKeyUsageWriter writer, @Named("auditExecutor") Executor auditExecutor) { + this.writer = writer; + this.auditExecutor = auditExecutor; + } /** Bump today's tally for the key and stamp last-used. */ - @Async("auditExecutor") public void record(Long apiKeyId) { if (apiKeyId == null) { return; } + // Was Spring @Async("auditExecutor"); the hand-off is explicit now that there is no proxy. + auditExecutor.execute(() -> recordUsage(apiKeyId)); + } + + private void recordUsage(Long apiKeyId) { try { long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay(); // First writer of the day inserts the row; everyone else (and the loser of an insert diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java index 574a1fa7f0..c96c03ca08 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java @@ -2,8 +2,6 @@ package stirling.software.proprietary.security.service; import java.time.Instant; -import org.springframework.transaction.annotation.Propagation; - import jakarta.enterprise.context.ApplicationScoped; import jakarta.persistence.PersistenceException; import jakarta.transaction.Transactional; @@ -28,7 +26,7 @@ class ApiKeyUsageWriter { private final ApiKeyDailyUsageRepository usageRepository; /** Bump today's tally if the row already exists; returns rows updated (0 if none yet). */ - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional(Transactional.TxType.REQUIRES_NEW) public int increment(Long apiKeyId, long epochDay) { return usageRepository.incrementIfPresent(apiKeyId, epochDay); } @@ -37,7 +35,7 @@ class ApiKeyUsageWriter { * Insert today's row with a count of 1. Flushes so a concurrent first-write's unique-key clash * surfaces here (returning false) instead of at commit; the caller then increments instead. */ - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional(Transactional.TxType.REQUIRES_NEW) public boolean tryInsertFirstUse(Long apiKeyId, long epochDay) { try { usageRepository.saveAndFlush(new ApiKeyDailyUsage(apiKeyId, epochDay, 1)); @@ -47,10 +45,10 @@ class ApiKeyUsageWriter { } } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional(Transactional.TxType.REQUIRES_NEW) public void stampLastUsed(Long apiKeyId) { apiKeyRepository - .findById(apiKeyId) + .findByIdOptional(apiKeyId) .ifPresent( key -> { key.setLastUsedAt(Instant.now()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java index 3514a66409..84b812cb74 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java @@ -178,7 +178,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { if (cached != null) { return Optional.of(cached); } - Optional entityOpt = keyRepository.findById(keyId); + Optional entityOpt = keyRepository.findByIdOptional(keyId); if (entityOpt.isEmpty()) { log.warn("No signing key found in DB for keyId: {}", keyId); return Optional.empty(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java index 5c0ea14eea..f874297c85 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java @@ -5,7 +5,7 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; -import org.springframework.core.env.Environment; +import org.eclipse.microprofile.config.Config; import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; @@ -29,10 +29,12 @@ import stirling.software.proprietary.security.repository.TeamMembershipRepositor public class TeamMembershipService { private final TeamMembershipRepository membershipRepository; - private final Environment environment; + private final Config config; private boolean isSaas() { - return Arrays.asList(environment.getActiveProfiles()).contains("saas"); + // Spring's Environment.getActiveProfiles() -> the Quarkus profile(s), comma separated. + String activeProfiles = config.getOptionalValue("quarkus.profile", String.class).orElse(""); + return Arrays.asList(activeProfiles.split(",")).contains("saas"); } /** Reflects users.team_id into membership rows, preserving an existing role on the team. */ @@ -87,7 +89,7 @@ public class TeamMembershipService { } /** Owner user ids for a team. */ - @Transactional(readOnly = true) + @Transactional public List ownerUserIds(Long teamId) { return membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream() .map(row -> row.getUser().getId()) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java index 6baee9a4b7..ebfac0396e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.service; import java.io.IOException; +import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -126,8 +127,8 @@ public class AiDocumentHtmlRenderer { } private static String loadTemplate() { - try { - return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8); + try (InputStream in = new ClassPathResource(TEMPLATE_PATH).getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java index 2ff5036e26..d4d8c9a3ee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java @@ -6,11 +6,11 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.stream.Collectors; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; +import io.quarkus.runtime.StartupEvent; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -48,7 +48,11 @@ public class AiEngineConfigSync { pushExecutor.shutdownNow(); } - @EventListener(ApplicationReadyEvent.class) + // Observer kept separate so the startup push stays directly callable. + void onStart(@Observes StartupEvent event) { + pushConfigOnStartup(); + } + public void pushConfigOnStartup() { AiEngine cfg = applicationProperties.getAiEngine(); if (!cfg.isEnabled()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiFeatureGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiFeatureGate.java index 2c3cb46a22..8f196598b8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiFeatureGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiFeatureGate.java @@ -1,9 +1,8 @@ package stirling.software.proprietary.service; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; - import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; @@ -26,8 +25,9 @@ public class AiFeatureGate { private void require(boolean featureEnabled, String feature) { if (!applicationProperties.getAiEngine().isEnabled() || !featureEnabled) { - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI feature '" + feature + "' is disabled"); + throw new WebApplicationException( + "AI feature '" + feature + "' is disabled", + Response.Status.SERVICE_UNAVAILABLE); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java index 2c136a6fa1..dad75e4de7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java @@ -2,9 +2,9 @@ package stirling.software.proprietary.service; import java.util.List; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; +import io.quarkus.cache.CacheKey; +import io.quarkus.cache.CacheResult; +import io.quarkus.panache.common.Page; import jakarta.enterprise.context.ApplicationScoped; @@ -20,7 +20,7 @@ import stirling.software.proprietary.repository.PersistentAuditEventRepository; @RequiredArgsConstructor public class PortalAuditReadService { - /** Cache name - registered with a short TTL in CacheConfig. */ + /** Cache name - given its short TTL by {@code quarkus.cache.caffeine."portalAuditEvents".*}. */ public static final String CACHE_NAME = "portalAuditEvents"; /** Newest rows to scan; each surface filters this down to what it shows. */ @@ -38,25 +38,28 @@ public class PortalAuditReadService { private final PersistentAuditEventRepository auditRepository; /** Recent whole-server events (admins). */ - @Cacheable(value = CACHE_NAME, key = "'server'") + @CacheResult(cacheName = CACHE_NAME) public List serverEvents() { - return toRows(auditRepository.findByTypeNotIn(NOISE_TYPES, recentPage()).getContent()); + return toRows(auditRepository.findByTypeNotIn(NOISE_TYPES).page(recentPage()).list()); } /** Recent events by the given principals (team scope). Empty principals yield an empty list. */ - @Cacheable(value = CACHE_NAME, key = "#cacheKey") - public List scopedEvents(String cacheKey, List principals) { + @CacheResult(cacheName = CACHE_NAME) + public List scopedEvents( + @CacheKey String cacheKey, List principals) { if (principals.isEmpty()) { return List.of(); } return toRows( auditRepository - .findByTypeNotInAndPrincipalIn(NOISE_TYPES, principals, recentPage()) - .getContent()); + .findByTypeNotInAndPrincipalIn(NOISE_TYPES, principals) + .page(recentPage()) + .list()); } - private static PageRequest recentPage() { - return PageRequest.of(0, SCAN_LIMIT, Sort.by(Sort.Direction.DESC, "timestamp")); + // The repository finders already sort newest-first, so this only bounds the scan window. + private static Page recentPage() { + return Page.of(0, SCAN_LIMIT); } private static List toRows(List events) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java index c43fa48177..ae1dcb6114 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java @@ -53,10 +53,13 @@ public class StorageProviderConfig { @Produces @Singleton public StorageEncryptionState storageEncryptionState( - @ConfigProperty(name = "stirling.security.fileEncryptionKey", defaultValue = "") - String configuredFileEncryptionKey, + // Optional, not defaultValue="": SmallRye Config reads an empty default as absent and + // then fails to convert it to String. + @ConfigProperty(name = "stirling.security.fileEncryptionKey") + Optional fileEncryptionKey, @ConfigProperty(name = "cluster.enabled", defaultValue = "false") boolean clusterEnabled) { + String configuredFileEncryptionKey = fileEncryptionKey.orElse(""); boolean writeEnabled = applicationProperties.getStorage().getEncryption().isEnabled(); if (writeEnabled) { licenseKeyChecker.requireProOrEnterprise("storage.encryption"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java index f98ccf1066..d8a21e5a82 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java @@ -395,6 +395,11 @@ public class EncryptingStorageProvider implements StorageProvider { return contentLength; } + @Override + public boolean isOpen() { + return true; + } + @Override public boolean exists() { return true; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java index 1fca2a0489..9721609a23 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java @@ -520,10 +520,10 @@ public class FileStorageService { "Access to stored file {} denied: {}", file != null ? file.getId() : null, e.getMessage()); - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, + throw new WebApplicationException( "Access to this file has been revoked (its encryption key is disabled)", - e); + e, + Response.Status.FORBIDDEN); } catch (IOException e) { log.error( "Failed to load stored file {} (key: {})", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java index 42ee9c7b36..d5f7a06a68 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java @@ -39,12 +39,15 @@ public class SignDocumentRequest { // Certificate/key upload parts. RESTEasy Reactive only populates @RestForm FileUpload fields // (not the common MultipartFile shim), so they are bound as FileUpload here and adapted back to - // MultipartFile via the getP12File()/getPrivateKeyFile()/getCertFile() accessors below, leaving - // the signing service unchanged. The frontend posts these exact part names (p12File for - // PKCS12/PFX/JKS keystores; privateKeyFile + certFile for PEM). + // MultipartFile via the getP12File()/getJksFile()/getPrivateKeyFile()/getCertFile() accessors + // below, leaving the signing service unchanged. The frontend posts these exact part names + // (p12File for PKCS12/PFX; jksFile for JKS; privateKeyFile + certFile for PEM). @RestForm("p12File") private FileUpload p12FileUpload; + @RestForm("jksFile") + private FileUpload jksFileUpload; + @RestForm("password") private String password; @@ -58,6 +61,10 @@ public class SignDocumentRequest { return FileUploadMultipartFile.of(p12FileUpload); } + public MultipartFile getJksFile() { + return FileUploadMultipartFile.of(jksFileUpload); + } + public MultipartFile getPrivateKeyFile() { return FileUploadMultipartFile.of(privateKeyFileUpload); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index ef63d42ca6..cea86a3b27 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -766,10 +766,10 @@ public class WorkflowSessionService { try { certificateSubmissionValidator.validateAndExtractInfo( request.getJksFile().getBytes(), "JKS", request.getPassword()); - } catch (ResponseStatusException e) { + } catch (WebApplicationException e) { throw e; } catch (IOException e) { - log.error("Failed to read P12 keystore file", e); + log.error("Failed to read JKS keystore file for validation", e); throw new WebApplicationException( "Failed to process certificate file", Response.Status.BAD_REQUEST); } @@ -806,8 +806,8 @@ public class WorkflowSessionService { request.getP12File().getBytes())); } catch (IOException e) { log.error("Failed to read P12 keystore file", e); - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Failed to process certificate file"); + throw new WebApplicationException( + "Failed to process certificate file", Response.Status.BAD_REQUEST); } } else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) { try { @@ -817,8 +817,8 @@ public class WorkflowSessionService { request.getJksFile().getBytes())); } catch (IOException e) { log.error("Failed to read JKS keystore file", e); - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Failed to process certificate file"); + throw new WebApplicationException( + "Failed to process certificate file", Response.Status.BAD_REQUEST); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java index 1838034a9a..3ec2a6b48d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java @@ -17,8 +17,8 @@ import stirling.software.proprietary.accountlink.GateDecision.Reason; /** * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired, - * linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no - * Spring; the grace-window reference computation is exercised through {@link + * linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no CDI + * container; the grace-window reference computation is exercised through {@link * InstanceEntitlementGate#evaluate} with mocked collaborators. */ @ExtendWith(MockitoExtension.class) @@ -241,7 +241,7 @@ class InstanceEntitlementGateTest { void evaluate_neverSynced_pastGraceSinceLink_blocks() { when(credentialStore.isLinked()).thenReturn(true); when(entitlementCache.current()).thenReturn(Optional.empty()); - when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + when(syncStateRepository.findByIdOptional(AccountLinkSyncState.SINGLETON_ID)) .thenReturn(Optional.empty()); DeviceCredential cred = new DeviceCredential(); cred.setLinkedAt(LocalDateTime.now().minusDays(5)); @@ -259,7 +259,7 @@ class InstanceEntitlementGateTest { when(entitlementCache.current()).thenReturn(Optional.empty()); AccountLinkSyncState state = new AccountLinkSyncState(); state.setLastSuccessAt(LocalDateTime.now().minusDays(1)); - when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + when(syncStateRepository.findByIdOptional(AccountLinkSyncState.SINGLETON_ID)) .thenReturn(Optional.of(state)); GateDecision d = gate(props(true, 3)).evaluate(true); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java index bfb534f9cc..772cb9abcc 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.when; import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; +import org.jboss.resteasy.reactive.multipart.FileUpload; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -22,9 +23,13 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import jakarta.enterprise.inject.Instance; + import stirling.software.common.model.MultipartFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.testsupport.TestFileUploads; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.classification.ClassificationLabelProvider; import stirling.software.proprietary.classification.model.ClassificationLabel; @@ -47,10 +52,15 @@ class ClassifyLabelControllerTest { @Mock private AiEngineClient aiEngineClient; @Mock private AiFeatureGate aiFeatureGate; + // Optional security bean: the migrated constructor resolves it via Instance#isResolvable(), so + // a plain null is no longer valid. An unresolvable Instance reproduces the no-user path. + @Mock private Instance userService; + private final ObjectMapper objectMapper = JsonMapper.builder().build(); private ClassifyLabelController controller; private void withLabels(List labels) { + when(userService.isResolvable()).thenReturn(false); controller = new ClassifyLabelController( pdfDocumentFactory, @@ -61,13 +71,12 @@ class ClassifyLabelControllerTest { aiFeatureGate, objectMapper, ClassificationLabelProvider.withLabels(labels), - null); + userService); } private void stubSinglePageDocument() throws Exception { PDDocument document = mock(PDDocument.class); - MultipartFile file = mock(MultipartFile.class); - when(file.getOriginalFilename()).thenReturn("invoice.pdf"); + FileUpload file = TestFileUploads.of(new byte[0], "invoice.pdf", "application/pdf"); when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document); when(document.getNumberOfPages()).thenReturn(1); when(pdfContentExtractor.extractPageTextRaw(document, 1)) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/PdfCommentAgentControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/PdfCommentAgentControllerTest.java index 112d50083f..fbc29b47ac 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/PdfCommentAgentControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/PdfCommentAgentControllerTest.java @@ -23,23 +23,21 @@ import jakarta.ws.rs.core.Response; import stirling.software.common.model.MultipartFile; import stirling.software.common.testsupport.TestFileUploads; +import stirling.software.proprietary.service.AiFeatureGate; import stirling.software.proprietary.service.PdfCommentAgentOrchestrator; import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf; import tools.jackson.databind.json.JsonMapper; /** - * MIGRATION (Spring -> Quarkus): {@code PdfCommentAgentController} is a JAX-RS resource taking a - * RESTEasy Reactive {@code FileUpload} + form {@code prompt} and returning {@link Response}. Tests - * call the handler directly with a {@link TestFileUploads} stub for the upload. + * Controller tests for {@link PdfCommentAgentController}. The orchestrator is mocked so the test + * never hits the engine or real filesystem, and the JAX-RS handler is called directly with a {@link + * TestFileUploads} stub for the upload. * - *

    The orchestrator is mocked so the test never hits the engine or real filesystem. Validation - * errors are now signalled by {@code WebApplicationException} (was Spring {@code - * ResponseStatusException}); the controller lets them propagate, so the error-path tests assert the - * thrown status rather than a MockMvc {@code status()} matcher. The former "missing required form - * param" tests (previously enforced by Spring's {@code DefaultHandlerExceptionResolver}) are kept - * as direct-call equivalents: a missing file arrives as {@code null} and the controller fails fast - * before reaching the orchestrator; a missing prompt is rejected by the orchestrator with 400. + *

    The controller lets validation failures propagate as {@link WebApplicationException}, so the + * error-path tests assert the thrown status. Missing form params are covered the same way: a + * missing file binds as {@code null} and the controller fails fast before reaching the + * orchestrator, while a missing prompt is rejected by the orchestrator with 400. */ @ExtendWith(MockitoExtension.class) class PdfCommentAgentControllerTest { @@ -54,6 +52,7 @@ class PdfCommentAgentControllerTest { controller = new PdfCommentAgentController(); controller.orchestrator = orchestrator; controller.objectMapper = JsonMapper.builder().build(); + controller.aiFeatureGate = aiFeatureGate; } @Test @@ -107,23 +106,20 @@ class PdfCommentAgentControllerTest { @Test void returnsServiceUnavailableWhenPdfCommentFeatureDisabled() throws Exception { - doThrow(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE)) + // The gate runs before any upload handling, so its 503 leaves the orchestrator untouched. + doThrow(new WebApplicationException(Response.Status.SERVICE_UNAVAILABLE)) .when(aiFeatureGate) .requirePdfComment(); - MockMultipartFile pdfFile = - new MockMultipartFile( - "fileInput", - "input.pdf", - MediaType.APPLICATION_PDF_VALUE, - "%PDF-1.4\n%%EOF".getBytes()); + FileUpload pdfFile = + TestFileUploads.of("%PDF-1.4\n%%EOF".getBytes(), "input.pdf", "application/pdf"); - mockMvc.perform( - multipart("/api/v1/ai/tools/pdf-comment-agent") - .file(pdfFile) - .param("prompt", "flag dates")) - .andExpect(status().isServiceUnavailable()); + WebApplicationException ex = + assertThrows( + WebApplicationException.class, + () -> controller.pdfCommentAgent(pdfFile, "flag dates")); + assertEquals(503, ex.getResponse().getStatus()); - verify(orchestrator, never()).applyComments(any(), anyString()); + verify(orchestrator, never()).applyComments(any(), any()); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java index 74afbeba39..571147949b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java @@ -1,12 +1,8 @@ package stirling.software.proprietary.controller.api; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import java.security.Principal; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -17,13 +13,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import io.quarkus.hibernate.orm.panache.PanacheQuery; import io.quarkus.security.identity.SecurityIdentity; import jakarta.ws.rs.core.Response; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData; @@ -44,14 +40,6 @@ import stirling.software.proprietary.service.UserLicenseSettingsService; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; -/** - * MIGRATION (Spring -> Quarkus): {@code ProprietaryUIDataController} endpoints now return JAX-RS - * {@link Response} (was {@code ResponseEntity}); {@code getAccountData()} takes no argument and - * reads the caller from the injected {@code SecurityIdentity} (was a Spring {@code Authentication} - * parameter); and {@code UserRepository} is a Panache repository, so {@code findAll()} returns a - * {@link PanacheQuery} (stubbed to {@code .list()} here). Assertions are adapted to the JAX-RS - * status code / entity API. - */ @ExtendWith(MockitoExtension.class) class ProprietaryUIDataControllerTest { @@ -65,6 +53,7 @@ class ProprietaryUIDataControllerTest { @Mock private PersistentAuditEventRepository auditRepository; @Mock private MfaService mfaService; @Mock private LoginAttemptService loginAttemptService; + @Mock private ResourceAccessService resourceAccessService; @Mock private SecurityIdentity securityIdentity; private ApplicationProperties applicationProperties; @@ -100,22 +89,15 @@ class ProprietaryUIDataControllerTest { licenseSettingsService, auditRepository, mfaService, - loginAttemptService); + loginAttemptService, + resourceAccessService); // securityIdentity is @Inject field injection - wire the mock directly (no CDI container). controller.securityIdentity = securityIdentity; } - @SuppressWarnings("unchecked") - private PanacheQuery queryOf(List users) { - PanacheQuery query = mock(PanacheQuery.class); - lenient().when(query.list()).thenReturn(users); - return query; - } - @Test void loginDataFlagsFirstTimeSetupWhenNoUsers() { - PanacheQuery emptyQuery = queryOf(Collections.emptyList()); - when(userRepository.findAll()).thenReturn(emptyQuery); + when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())).thenReturn(0L); Response response = controller.getLoginData(); @@ -141,10 +123,9 @@ class ProprietaryUIDataControllerTest { when(mfaService.isMfaEnabled(user)).thenReturn(true); when(mfaService.isMfaRequired(user)).thenReturn(false); - Principal principal = mock(Principal.class); - when(principal.getName()).thenReturn("user@example.com"); + // The User entity is itself the SecurityIdentity principal (implements Principal). when(securityIdentity.isAnonymous()).thenReturn(false); - when(securityIdentity.getPrincipal()).thenReturn(principal); + when(securityIdentity.getPrincipal()).thenReturn(user); Response response = controller.getAccountData(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java index 898f6788d4..0d053deba0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.io.TempDir; import io.smallrye.config.SmallRyeConfig; import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -29,10 +30,6 @@ import stirling.software.proprietary.policy.source.SourceStore; /** * Tests for {@link FolderAccessGuard}: folder access is fail-closed, confined to the configured * allowed roots, never reaches Stirling's own config directory, and is off entirely under SaaS. - * - *

    MIGRATION (Spring -> Quarkus): the guard now reads the active profile from MicroProfile {@link - * Config} ({@code config.unwrap(SmallRyeConfig.class).getProfiles()}) instead of Spring's {@code - * StandardEnvironment.setActiveProfiles(...)}. The {@code Config} is mocked accordingly. */ class FolderAccessGuardTest { @@ -43,7 +40,34 @@ class FolderAccessGuardTest { private FolderAccessGuard guard(List allowedRoots, String... activeProfiles) { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(allowedRoots); - return new FolderAccessGuard(properties, configWithProfiles(activeProfiles)); + return new FolderAccessGuard( + properties, + new RuntimePathConfig(properties), + configWithProfiles(activeProfiles), + sourceStore); + } + + private FolderAccessGuard guardWithStorage( + List allowedRoots, boolean storageEnabled, String provider, String basePath) { + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowedFolderRoots(allowedRoots); + ApplicationProperties.Storage storage = properties.getStorage(); + storage.setEnabled(storageEnabled); + storage.setProvider(provider); + storage.getLocal().setBasePath(basePath); + return new FolderAccessGuard( + properties, new RuntimePathConfig(properties), configWithProfiles(), sourceStore); + } + + private FolderAccessGuard guardWithWatchedFolder(String watchedDir) { + ApplicationProperties properties = new ApplicationProperties(); + properties + .getSystem() + .getCustomPaths() + .getPipeline() + .setWatchedFoldersDirs(List.of(watchedDir)); + return new FolderAccessGuard( + properties, new RuntimePathConfig(properties), configWithProfiles(), sourceStore); } /** diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java index 61e416cd89..ffdd75d92f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java @@ -57,11 +57,6 @@ import tools.jackson.databind.json.JsonMapper; * steps, multi-input vs per-file dispatch, ZIP unpacking, structured-list parameter encoding, * progress callbacks, and timeout propagation. External collaborators are mocked; {@link * TempFileManager} is real so ZIP extraction exercises real code. - * - *

    MIGRATION (Spring -> Quarkus): {@link InternalApiClient} now takes a {@code - * Map>} body (was Spring {@code MultiValueMap}) and returns a {@link Response} - * (was {@code ResponseEntity}); file parts are the {@link Resource} shim (was Spring's - * {@code org.springframework.core.io.Resource}). */ @ExtendWith(MockitoExtension.class) class PolicyExecutorTest { @@ -181,9 +176,7 @@ class PolicyExecutorTest { assertEquals(1, result.files().size()); assertEquals("purchase-order.pdf", result.files().get(0).getFilename()); - @SuppressWarnings("unchecked") - ArgumentCaptor> bodyCaptor = - ArgumentCaptor.forClass(MultiValueMap.class); + ArgumentCaptor>> bodyCaptor = bodyCaptor(); verify(internalApiClient, times(1)).post(eq(createPdf), bodyCaptor.capture()); // No document stream: the body carries only the generator's parameters, no fileInput. assertNull(bodyCaptor.getValue().get("fileInput")); @@ -403,8 +396,7 @@ class PolicyExecutorTest { /** * In-memory {@link Resource} with a stable filename, repeatable reads, and a real {@code - * contentLength()} (so the executor's empty-body / ZIP handling works). Replaces the Spring - * {@code ByteArrayResource} the test used pre-migration. + * contentLength()} (so the executor's empty-body / ZIP handling works). */ private static final class ByteArrayBackedResource implements Resource { private final byte[] bytes; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java index 6fa8feba16..d396a0258d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java @@ -30,18 +30,17 @@ import org.mockito.junit.jupiter.MockitoExtension; import io.smallrye.config.SmallRyeConfig; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.source.InProcessSourceStore; /** - * Tests for {@link FolderInputSource}: consume (claim + route) and snapshot (read-only) modes. - * - *

    MIGRATION (Spring -> Quarkus): {@link FolderAccessGuard} now reads active profiles from - * MicroProfile {@link Config} instead of Spring's {@code StandardEnvironment}; a {@code Config} - * reporting no active profile is supplied here. + * Tests for {@link FolderInputSource}: consume mode tracks files in place through the ledger, + * snapshot stays stateless, and discovery skips hidden entries and honours the recursive option. */ @ExtendWith(MockitoExtension.class) class FolderInputSourceTest { @@ -60,7 +59,12 @@ class FolderInputSourceTest { void setUp() { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); - FolderAccessGuard guard = new FolderAccessGuard(properties, configWithNoProfiles()); + FolderAccessGuard guard = + new FolderAccessGuard( + properties, + new RuntimePathConfig(properties), + configWithNoProfiles(), + new InProcessSourceStore()); source = new FolderInputSource(readinessChecker, guard); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java index 3422874236..3b84b7f3b5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java @@ -35,11 +35,8 @@ import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.source.InProcessSourceStore; /** - * Tests for {@link FolderOutputSink}: outputs are written to the configured directory on disk. - * - *

    MIGRATION (Spring -> Quarkus): {@link FolderAccessGuard} now reads active profiles from - * MicroProfile {@link Config} (was Spring {@code StandardEnvironment}); output files are the {@link - * Resource} shim (was Spring's {@code org.springframework.core.io.Resource}). + * Tests for {@link FolderOutputSink}: outputs are staged hidden, recorded in the ledger, then + * atomically renamed into the configured directory. */ class FolderOutputSinkTest { @@ -53,9 +50,8 @@ class FolderOutputSinkTest { @BeforeEach void setUp() { - ApplicationProperties properties = new ApplicationProperties(); - properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); - sink = new FolderOutputSink(new FolderAccessGuard(properties, configWithNoProfiles())); + ledger = new InProcessProcessedLedger(); + sink = new FolderOutputSink(guardRootedAt(tempDir), ledger); } @Test @@ -113,16 +109,7 @@ class FolderOutputSinkTest { void recordsAnOutputBeforeItBecomesVisible() throws IOException { Path out = tempDir.resolve("out"); VisibilityAssertingLedger orderedLedger = new VisibilityAssertingLedger(); - ApplicationProperties properties = new ApplicationProperties(); - properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); - FolderOutputSink orderedSink = - new FolderOutputSink( - new FolderAccessGuard( - properties, - new RuntimePathConfig(properties), - new StandardEnvironment(), - new InProcessSourceStore()), - orderedLedger); + FolderOutputSink orderedSink = new FolderOutputSink(guardRootedAt(tempDir), orderedLedger); orderedSink.deliver( POLICY_RUN, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); @@ -185,6 +172,17 @@ class FolderOutputSinkTest { assertFalse(Files.exists(tempDir.resolve("escape.pdf"))); } + /** A guard that allows only {@code root}, with the {@code saas} profile inactive. */ + private static FolderAccessGuard guardRootedAt(Path root) { + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowedFolderRoots(List.of(root.toString())); + return new FolderAccessGuard( + properties, + new RuntimePathConfig(properties), + configWithNoProfiles(), + new InProcessSourceStore()); + } + /** A {@link Config} whose unwrapped {@link SmallRyeConfig} reports no active profile. */ private static Config configWithNoProfiles() { SmallRyeConfig smallRyeConfig = mock(SmallRyeConfig.class); @@ -198,10 +196,7 @@ class FolderOutputSinkTest { return new ByteArrayBackedResource(content.getBytes(), filename); } - /** - * In-memory {@link Resource} with a stable filename (replaces Spring's {@code - * ByteArrayResource}). - */ + /** In-memory {@link Resource} with a stable filename. */ private static final class ByteArrayBackedResource implements Resource { private final byte[] bytes; private final String filename; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java index 41f80461cd..80bf38149e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java @@ -58,7 +58,7 @@ class EmbeddedS3CredentialMigrationTest { new InProcessCompletedMigrations()); AtomicLong ids = new AtomicLong(100); // Lenient: the nothing-to-migrate cases never create a connection. - lenient().when(connections.findAll()).thenReturn(List.of()); + lenient().when(connections.listAll()).thenReturn(List.of()); lenient() .when(connections.save(any())) .thenAnswer( @@ -75,7 +75,7 @@ class EmbeddedS3CredentialMigrationTest { void extractsSharedCredentialsIntoOneTeamScopedConnection() { Team team = new Team(); team.setId(7L); - when(teamRepository.findById(7L)).thenReturn(Optional.of(team)); + when(teamRepository.findByIdOptional(7L)).thenReturn(Optional.of(team)); Source source = sourceStore.save( new Source( @@ -132,7 +132,7 @@ class EmbeddedS3CredentialMigrationTest { void connectionOwnershipFollowsTheSourceTeam() { Team team = new Team(); team.setId(7L); - when(teamRepository.findById(7L)).thenReturn(Optional.of(team)); + when(teamRepository.findByIdOptional(7L)).thenReturn(Optional.of(team)); sourceStore.save(s3Source("teamed", 7L)); migration.migrate(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java index c5f8c9c936..cc16d307f5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java @@ -4,12 +4,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,6 +20,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import io.quarkus.hibernate.orm.panache.PanacheQuery; + import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; @@ -69,7 +73,7 @@ class JpaSourceStoreTest { void getDeserializesTheSourceFromJson() { Source source = new Source("s1", "Claims", "folder", Map.of("directory", "/in"), true, "alice", 1L); - when(repository.findById("s1")).thenReturn(Optional.of(entityFor(source))); + when(repository.findByIdOptional("s1")).thenReturn(Optional.of(entityFor(source))); assertEquals(source, store.get("s1").orElseThrow()); } @@ -77,7 +81,7 @@ class JpaSourceStoreTest { @Test void allDeserializesEverySource() { Source a = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 1L); - when(repository.findAll()).thenReturn(List.of(entityFor(a))); + when(repository.findAll()).thenReturn(queryOf(entityFor(a))); assertEquals(List.of(a), store.all()); } @@ -100,6 +104,14 @@ class JpaSourceStoreTest { assertFalse(store.delete("missing")); } + // Panache findAll() hands back a query, and the store streams it. + @SuppressWarnings("unchecked") + private PanacheQuery queryOf(SourceEntity... entities) { + PanacheQuery query = mock(PanacheQuery.class); + when(query.stream()).thenReturn(Stream.of(entities)); + return query; + } + private SourceEntity entityFor(Source source) { SourceEntity entity = new SourceEntity(); entity.setId(source.id()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java index 490c5fe410..a70d5704cd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.security.Principal; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -18,13 +19,17 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import io.quarkus.hibernate.orm.panache.PanacheQuery; + import jakarta.enterprise.inject.Instance; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.SecurityContext; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.api.security.UserSummaryDTO; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.repository.TeamRepository; @@ -74,7 +79,8 @@ class UserControllerTest { userRepository, emailServiceInstance(), licenseSettingsService, - loginAttemptService); + loginAttemptService, + teamMembershipService); } @SuppressWarnings("unchecked") @@ -173,4 +179,281 @@ class UserControllerTest { verify(loginAttemptService).resetAttempts("lockeduser"); } + + // --------------------------------------------------------------------- + // GET /api/v1/user/users - storage.signing.userListScope scoping + // --------------------------------------------------------------------- + + private static User user(long id, String username, boolean enabled, Team team) { + User u = new User(); + u.setId(id); + u.setUsername(username); + u.setEnabled(enabled); + u.setTeam(team); + return u; + } + + private static Team team(long id, String name) { + Team t = new Team(); + t.setId(id); + t.setName(name); + return t; + } + + // Panache findAll() hands back a query the controller calls list() on. + @SuppressWarnings("unchecked") + private static PanacheQuery queryOf(User... users) { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of(users)); + return query; + } + + @SuppressWarnings("unchecked") + private static List userList(Response response) { + return (List) response.getEntity(); + } + + // No principal at all: JAX-RS still injects a context, getUserPrincipal() is just null. + private void unauthenticated() { + controller.securityContext = mock(SecurityContext.class); + } + + @Test + void listUsersDefaultScopeIsOrgWide() { + // Default "org" scope returns every enabled user via findAll(), no team lookup. + Team alpha = team(1L, "alpha"); + when(userRepository.findAll()) + .thenReturn( + queryOf( + user(1L, "a@alpha.com", true, alpha), + user(2L, "b@alpha.com", true, alpha))); + authenticateAs("a@alpha.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + List users = userList(response); + assertEquals(2, users.size()); + assertEquals("a@alpha.com", users.get(0).getUsername()); + assertEquals("b@alpha.com", users.get(1).getUsername()); + + // Caller is resolved (for the anonymous-gate) but org scope still uses findAll, not team. + verify(userRepository, never()).findAllByTeamId(any()); + } + + @Test + void listUsersForbiddenForAnonymousCaller() { + // Anonymous SaaS accounts must never enumerate users, regardless of scope. + User anon = user(1L, "anon_abc", true, team(1L, TeamService.DEFAULT_TEAM_NAME)); + anon.setAuthenticationType(AuthenticationType.ANONYMOUS); + when(userService.findByUsernameIgnoreCase("anon_abc")).thenReturn(Optional.of(anon)); + authenticateAs("anon_abc"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + + verify(userRepository, never()).findAll(); + verify(userRepository, never()).findAllByTeamId(any()); + } + + @Test + void listUsersOrgScopeFiltersDisabledUsers() { + Team alpha = team(1L, "alpha"); + when(userRepository.findAll()) + .thenReturn( + queryOf( + user(1L, "enabled@alpha.com", true, alpha), + user(2L, "disabled@alpha.com", false, alpha))); + authenticateAs("enabled@alpha.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + List users = userList(response); + assertEquals(1, users.size()); + assertEquals("enabled@alpha.com", users.get(0).getUsername()); + } + + @Test + void listUsersTeamScopeReturnsOnlyCallerTeam() { + applicationProperties.getStorage().getSigning().setUserListScope("team"); + Team alpha = team(7L, "alpha"); + User caller = user(1L, "caller@alpha.com", true, alpha); + when(userService.findByUsernameIgnoreCase("caller@alpha.com")) + .thenReturn(Optional.of(caller)); + when(userRepository.findAllByTeamId(7L)) + .thenReturn(List.of(caller, user(2L, "mate@alpha.com", true, alpha))); + authenticateAs("caller@alpha.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + List users = userList(response); + assertEquals(2, users.size()); + assertEquals("alpha", users.get(0).getTeamName()); + + verify(userRepository).findAllByTeamId(7L); + verify(userRepository, never()).findAll(); + } + + @Test + void listUsersTeamScopeWithMissingCallerReturnsEmpty() { + applicationProperties.getStorage().getSigning().setUserListScope("team"); + when(userService.findByUsernameIgnoreCase("ghost@alpha.com")).thenReturn(Optional.empty()); + authenticateAs("ghost@alpha.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(0, userList(response).size()); + + verify(userRepository, never()).findAllByTeamId(any()); + verify(userRepository, never()).findAll(); + } + + @Test + void listUsersTeamScopeWithNullTeamReturnsSelfOnly() { + applicationProperties.getStorage().getSigning().setUserListScope("team"); + User caller = user(1L, "solo@nowhere.com", true, null); + when(userService.findByUsernameIgnoreCase("solo@nowhere.com")) + .thenReturn(Optional.of(caller)); + authenticateAs("solo@nowhere.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + List users = userList(response); + assertEquals(1, users.size()); + assertEquals("solo@nowhere.com", users.get(0).getUsername()); + + verify(userRepository, never()).findAllByTeamId(any()); + verify(userRepository, never()).findAll(); + } + + @Test + void listUsersTeamScopeOnDefaultTeamReturnsSelfOnly() { + // A caller on a shared system team must not enumerate its members. + applicationProperties.getStorage().getSigning().setUserListScope("team"); + Team defaultTeam = team(1L, TeamService.DEFAULT_TEAM_NAME); + User caller = user(1L, "new@saas.com", true, defaultTeam); + when(userService.findByUsernameIgnoreCase("new@saas.com")).thenReturn(Optional.of(caller)); + authenticateAs("new@saas.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + List users = userList(response); + assertEquals(1, users.size()); + assertEquals("new@saas.com", users.get(0).getUsername()); + + verify(userRepository, never()).findAllByTeamId(any()); + verify(userRepository, never()).findAll(); + } + + @Test + void listUsersTeamScopeOnInternalTeamReturnsSelfOnly() { + applicationProperties.getStorage().getSigning().setUserListScope("team"); + Team internalTeam = team(2L, TeamService.INTERNAL_TEAM_NAME); + User caller = user(1L, "svc@saas.com", true, internalTeam); + when(userService.findByUsernameIgnoreCase("svc@saas.com")).thenReturn(Optional.of(caller)); + authenticateAs("svc@saas.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + List users = userList(response); + assertEquals(1, users.size()); + assertEquals("svc@saas.com", users.get(0).getUsername()); + + verify(userRepository, never()).findAllByTeamId(any()); + verify(userRepository, never()).findAll(); + } + + @Test + void listUsersFailsClosedOnUnrecognisedScope() { + // Any non-"org" value must restrict to the caller's team, not leak the instance. + applicationProperties.getStorage().getSigning().setUserListScope("tewm"); + Team alpha = team(3L, "alpha"); + when(userService.findByUsernameIgnoreCase("caller@alpha.com")) + .thenReturn(Optional.of(user(1L, "caller@alpha.com", true, alpha))); + when(userRepository.findAllByTeamId(3L)) + .thenReturn(List.of(user(1L, "caller@alpha.com", true, alpha))); + authenticateAs("caller@alpha.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + verify(userRepository).findAllByTeamId(3L); + verify(userRepository, never()).findAll(); + } + + @Test + void listUsersFailsClosedOnBlankScope() { + applicationProperties.getStorage().getSigning().setUserListScope(" "); + Team alpha = team(4L, "alpha"); + when(userService.findByUsernameIgnoreCase("caller@alpha.com")) + .thenReturn(Optional.of(user(1L, "caller@alpha.com", true, alpha))); + when(userRepository.findAllByTeamId(4L)).thenReturn(List.of()); + authenticateAs("caller@alpha.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + verify(userRepository).findAllByTeamId(4L); + verify(userRepository, never()).findAll(); + } + + @Test + void listUsersFailsClosedOnNullScope() { + // A null value must also fail closed to the caller's team. + applicationProperties.getStorage().getSigning().setUserListScope(null); + Team alpha = team(9L, "alpha"); + when(userService.findByUsernameIgnoreCase("caller@alpha.com")) + .thenReturn(Optional.of(user(1L, "caller@alpha.com", true, alpha))); + when(userRepository.findAllByTeamId(9L)).thenReturn(List.of()); + authenticateAs("caller@alpha.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + verify(userRepository).findAllByTeamId(9L); + verify(userRepository, never()).findAll(); + } + + @Test + void listUsersOrgScopeIsCaseInsensitive() { + applicationProperties.getStorage().getSigning().setUserListScope("ORG"); + when(userRepository.findAll()).thenReturn(queryOf(user(1L, "a@alpha.com", true, null))); + authenticateAs("a@alpha.com"); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + verify(userRepository).findAll(); + verify(userRepository, never()).findAllByTeamId(any()); + } + + @Test + void listUsersRequiresAuthentication() { + unauthenticated(); + + Response response = controller.listUsers(); + + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + + verify(userRepository, never()).findAll(); + verify(userRepository, never()).findAllByTeamId(any()); + } + + @Test + void signingUserListScopeDefaultsToOrg() { + // Self-host backward-compat: default must stay "org" (saas profile flips it to "team"). + assertEquals( + "org", new ApplicationProperties().getStorage().getSigning().getUserListScope()); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java index c4e108d933..c3d83c7828 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java @@ -60,7 +60,7 @@ class ApiKeyAuthenticationServiceTest { String raw = "raw-1"; when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) .thenReturn(Optional.of(key(1, 7, true, null))); - when(userRepository.findById(7L)).thenReturn(Optional.of(user(7, true))); + when(userRepository.findByIdOptional(7L)).thenReturn(Optional.of(user(7, true))); var result = service.authenticate(raw); @@ -87,7 +87,7 @@ class ApiKeyAuthenticationServiceTest { String raw = "raw-3"; when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) .thenReturn(Optional.of(key(3, 8, true, null))); - when(userRepository.findById(8L)).thenReturn(Optional.of(user(8, false))); + when(userRepository.findByIdOptional(8L)).thenReturn(Optional.of(user(8, false))); assertThat(service.authenticate(raw)).isEmpty(); } @@ -122,7 +122,7 @@ class ApiKeyAuthenticationServiceTest { owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner)); when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) .thenReturn(Optional.of(key(6, 8, true, null))); - when(userRepository.findById(8L)).thenReturn(Optional.of(owner)); + when(userRepository.findByIdOptional(8L)).thenReturn(Optional.of(owner)); var result = service.authenticate(raw); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorderTest.java index 6a03414585..df34371c2e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorderTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorderTest.java @@ -8,16 +8,16 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; /** - * Unit tests for the increment/insert/increment race protocol. {@code @Async} has no proxy in a - * plain Mockito test, so {@code record()} runs inline and is directly testable. + * Unit tests for the increment/insert/increment race protocol. The audit executor runs tasks inline + * here, so {@code record()} completes before the assertions and is directly testable. */ @ExtendWith(MockitoExtension.class) @DisplayName("ApiKeyUsageRecorder") @@ -26,7 +26,12 @@ class ApiKeyUsageRecorderTest { private static final long KEY = 7L; @Mock private ApiKeyUsageWriter writer; - @InjectMocks private ApiKeyUsageRecorder recorder; + private ApiKeyUsageRecorder recorder; + + @BeforeEach + void setUp() { + recorder = new ApiKeyUsageRecorder(writer, Runnable::run); + } @Test @DisplayName("a null key id is a no-op") diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java index 68bd2d1727..190c20b8f3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.security.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -9,6 +10,8 @@ import static org.mockito.Mockito.when; import java.util.List; import java.util.Optional; +import org.eclipse.microprofile.config.Config; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -26,15 +29,16 @@ import stirling.software.proprietary.security.repository.TeamMembershipRepositor class TeamMembershipServiceTest { @Mock private TeamMembershipRepository membershipRepository; - @Mock private org.springframework.core.env.Environment environment; + @Mock private Config config; @InjectMocks private TeamMembershipService service; - @org.junit.jupiter.api.BeforeEach + @BeforeEach void notSaas() { - org.mockito.Mockito.lenient() - .when(environment.getActiveProfiles()) - .thenReturn(new String[] {}); + // No active profile, so the saas short-circuit does not fire. + lenient() + .when(config.getOptionalValue("quarkus.profile", String.class)) + .thenReturn(Optional.of("")); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index 3cd26f749f..3f989d8d2c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -46,8 +46,7 @@ import stirling.software.proprietary.workflow.service.UserServerCertificateServi /** * MIGRATION (Spring -> Quarkus): {@link UserService} now depends on the {@code - * stirling.software.common.security.PasswordEncoder} shim (was Spring's {@code - * org.springframework.security.crypto.password.PasswordEncoder}); the Spring {@code MessageSource} + * stirling.software.common.security.PasswordEncoder} shim; the Spring {@code MessageSource} * dependency was removed (localization deferred), so it is no longer mocked. Persistence uses the * Panache repository API: {@code persist(...)} (void) for inserts/updates and per-item {@code * delete(...)} for cleanup, replacing Spring Data's {@code save(...)} / {@code deleteAll(List)}. @@ -205,8 +204,6 @@ class UserServiceTest { user.setApiKey("old-secret"); when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user)); when(userRepository.findByApiKey(any())).thenReturn(Optional.empty()); - when(userRepository.save(any(User.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); User updated = userService.addApiKeyToUser("user"); @@ -215,7 +212,7 @@ class UserServiceTest { // authenticating. org.mockito.InOrder inOrder = inOrder(apiKeyAuthenticationService, userRepository); inOrder.verify(apiKeyAuthenticationService).revokeMigratedKey("old-secret"); - inOrder.verify(userRepository).save(user); + inOrder.verify(userRepository).persist(user); assertNotEquals("old-secret", updated.getApiKey()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java index 38741fe67a..fbec2dae24 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java @@ -250,8 +250,8 @@ class AiWorkflowServiceTest { @Test void mergeOutputHasNoSourceIndex() throws IOException { - MockMultipartFile a = pdf("a.pdf", "a-bytes"); - MockMultipartFile b = pdf("b.pdf", "b-bytes"); + FileUpload a = pdf("a.pdf", "a-bytes"); + FileUpload b = pdf("b.pdf", "b-bytes"); stubOrchestrator( """ {"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Merging"} @@ -263,7 +263,7 @@ class AiWorkflowServiceTest { stubFileStorage(); AiWorkflowResponse result = - service.orchestrate(requestFor(new MockMultipartFile[] {a, b}, "merge these")); + service.orchestrate(requestFor(new FileUpload[] {a, b}, "merge these")); // A merge draws on several inputs, so there is no single source to version in place. assertNull(result.getResultFiles().get(0).getSourceIndex()); @@ -271,7 +271,7 @@ class AiWorkflowServiceTest { @Test void splitOutputsHaveNoSourceIndex() throws IOException { - MockMultipartFile input = pdf("doc.pdf", "original"); + FileUpload input = pdf("doc.pdf", "original"); stubOrchestrator( """ {"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Splitting"} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java index e235bd81a3..6bb9836839 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java @@ -27,6 +27,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.Storage; import stirling.software.common.model.ApplicationProperties.Storage.Signing; import stirling.software.common.model.multipart.ByteArrayMultipartFile; +import stirling.software.common.testsupport.TestFileUploads; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.storage.model.StoredFile; @@ -173,7 +174,6 @@ class WorkflowSessionServiceTest { User user = user("dave"); WorkflowParticipant participant = pendingParticipant(user); sessionWithParticipant("s7", participant); - when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0)); byte[] p12Bytes; try (var in = getClass().getResourceAsStream("/test-certs/valid-test.p12")) { @@ -184,15 +184,15 @@ class WorkflowSessionServiceTest { SignDocumentRequest req = new SignDocumentRequest(); req.setCertType("PKCS12"); req.setPassword("changeit"); - req.setP12File( - new MockMultipartFile( - "p12File", "valid-test.p12", "application/x-pkcs12", p12Bytes)); + // The keystore part is bound as a RESTEasy FileUpload and adapted by getP12File() + req.setP12FileUpload( + TestFileUploads.of(p12Bytes, "valid-test.p12", "application/x-pkcs12")); svc.signDocument("s7", user, req); ArgumentCaptor captor = ArgumentCaptor.forClass(WorkflowParticipant.class); - verify(workflowParticipantRepository).save(captor.capture()); + verify(workflowParticipantRepository).persist(captor.capture()); @SuppressWarnings("unchecked") Map cert =