mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
port remaining proprietary sources to quarkus
This commit is contained in:
+100
-136
@@ -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<T>` -> `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<E>`; 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<T, ID>` -> `implements PanacheRepositoryBase<T, ID>` 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<T>`.
|
||||
|
||||
- [ ] `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<T>` -> 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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.io.InputStream;
|
||||
* <p>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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+10
-7
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+87
-39
@@ -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<ResourceGrant> 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<ResourceGrant> 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<String, Object> toDto(ResourceGrant g) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", g.getId());
|
||||
|
||||
+52
-18
@@ -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<ResourceGrant, Long> {
|
||||
public class ResourceGrantRepository implements PanacheRepositoryBase<ResourceGrant, Long> {
|
||||
|
||||
List<ResourceGrant> findByResourceTypeAndResourceId(
|
||||
ResourceType resourceType, String resourceId);
|
||||
public List<ResourceGrant> findByResourceTypeAndResourceId(
|
||||
ResourceType resourceType, String resourceId) {
|
||||
return list("resourceType = ?1 and resourceId = ?2", resourceType, resourceId);
|
||||
}
|
||||
|
||||
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType, PrincipalType principalType, Long principalId);
|
||||
public List<ResourceGrant> 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<ResourceGrant> findByPrincipalTypeAndPrincipalId(
|
||||
PrincipalType principalType, Long principalId);
|
||||
public List<ResourceGrant> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
|
||||
+11
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 ----
|
||||
|
||||
|
||||
+2
-3
@@ -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;
|
||||
* <p>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";
|
||||
|
||||
+71
-47
@@ -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).
|
||||
*
|
||||
* <p>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<UsageSyncService> syncServiceProvider;
|
||||
// Metering has its own flag; with it off (or the bean absent) /sync-now reports 409.
|
||||
private final Instance<UsageSyncService> syncServiceProvider;
|
||||
private final AccountLinkProperties properties;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> syncServiceProvider) {
|
||||
Instance<UsageSyncService> 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<AccountLinkService.LinkStatus> status() {
|
||||
return ResponseEntity.ok(service.status());
|
||||
@GET
|
||||
@Path("/status")
|
||||
public Response status() {
|
||||
requireEnabled();
|
||||
return Response.ok(service.status()).build();
|
||||
}
|
||||
|
||||
@PostMapping("/unlink")
|
||||
public ResponseEntity<Void> 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<LocalUsageService.LocalUsage> 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<Void> 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();
|
||||
}
|
||||
}
|
||||
|
||||
+39
-1
@@ -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 <T> Optional<T> read(Config config, String name, Class<T> type) {
|
||||
Optional<T> 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, <b>separate</b> 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
|
||||
|
||||
+2
-3
@@ -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;
|
||||
|
||||
+21
-2
@@ -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<AccountLinkSyncState, Long> {}
|
||||
@ApplicationScoped
|
||||
public class AccountLinkSyncStateRepository
|
||||
implements PanacheRepositoryBase<AccountLinkSyncState, Long> {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-27
@@ -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).
|
||||
*
|
||||
* <p>Whole config is gated behind {@code stirling.billing.account-link.enabled} +
|
||||
* {@code @IfBuildProfile("!saas")}; absent when off, so no interceptor is registered.
|
||||
* <p>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) == '/';
|
||||
}
|
||||
}
|
||||
|
||||
+18
-4
@@ -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<DeviceCredential, Long> {
|
||||
public class DeviceCredentialRepository implements PanacheRepositoryBase<DeviceCredential, Long> {
|
||||
|
||||
/** The singleton credential, if this instance has linked. */
|
||||
default Optional<DeviceCredential> findCredential() {
|
||||
return findById(DeviceCredential.SINGLETON_ID);
|
||||
public Optional<DeviceCredential> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-7
@@ -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.
|
||||
*
|
||||
* <p>Gated + {@code @IfBuildProfile("!saas")}: only the self-hosted profile links outward to a SaaS
|
||||
* team.
|
||||
* <p>{@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<DeviceCredential> get() {
|
||||
return repo.findCredential();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Transactional
|
||||
public boolean isLinked() {
|
||||
return repo.findCredential().isPresent();
|
||||
}
|
||||
|
||||
+2
-3
@@ -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;
|
||||
|
||||
+6
-7
@@ -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;
|
||||
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
|
||||
* </ol>
|
||||
*
|
||||
* <p>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
|
||||
* <p>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) {
|
||||
|
||||
+116
-71
@@ -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;
|
||||
*
|
||||
* <p>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<UsageMeterService> meterProvider;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
public InstanceEntitlementInterceptor(
|
||||
InstanceEntitlementGate gate,
|
||||
EntitlementCache entitlementCache,
|
||||
ObjectProvider<UsageMeterService> 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<UsageMeterService> 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<Part> 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<FileSize> sizes = new ArrayList<>();
|
||||
List<String> hashes = new ArrayList<>();
|
||||
int fileCount = 0;
|
||||
for (List<MultipartFile> 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<Part> fileParts(HttpServletRequest request) {
|
||||
String contentType = request.getContentType();
|
||||
if (contentType == null || !contentType.toLowerCase().startsWith("multipart/form-data")) {
|
||||
return null;
|
||||
}
|
||||
List<Part> files = new ArrayList<>();
|
||||
try {
|
||||
Collection<Part> 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");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -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;
|
||||
|
||||
+32
-5
@@ -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<MeteredInputSignature, Long> {
|
||||
@ApplicationScoped
|
||||
public class MeteredInputSignatureRepository
|
||||
implements PanacheRepositoryBase<MeteredInputSignature, Long> {
|
||||
|
||||
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
|
||||
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
|
||||
LocalDateTime periodStart, String signature);
|
||||
public Optional<MeteredInputSignature> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+44
-28
@@ -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<UsageCounter, Long> {
|
||||
@ApplicationScoped
|
||||
public class UsageCounterRepository implements PanacheRepositoryBase<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
* 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<UsageCounter> findByPeriodStart(LocalDateTime periodStart);
|
||||
public List<UsageCounter> 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<LocalDateTime> findPeriodsWithUnsyncedUsage();
|
||||
public List<LocalDateTime> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -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;
|
||||
|
||||
+33
-19
@@ -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<DeviceCredential> 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();
|
||||
|
||||
+17
-12
@@ -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<Resource> 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());
|
||||
|
||||
|
||||
+12
-9
@@ -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.
|
||||
|
||||
+29
-20
@@ -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<PortalApiKeysResponse> 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<CreatedApiKeyDto> 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<Void> revoke(@PathVariable("id") Long id) {
|
||||
public Response revoke(@PathParam("id") Long id) {
|
||||
apiKeyManagementService.revokeKey(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
return Response.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -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<PortalDocumentsResponseDto> 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();
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -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<InfraAuditLogResponse> 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();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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<String, Object> 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
|
||||
|
||||
+3
-3
@@ -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())));
|
||||
|
||||
+74
-56
@@ -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;
|
||||
* </ul>
|
||||
*/
|
||||
@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<Resource> 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<String, String> 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<Resource> 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<Resource> 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. */
|
||||
|
||||
+46
-8
@@ -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. */
|
||||
|
||||
+79
-53
@@ -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<IntegrationConfigResponse>> 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<IntegrationConfigResponse> 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<IntegrationCapabilitiesResponse> 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<IntegrationConfigResponse> 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<IntegrationConfigResponse> 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<Void> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-3
@@ -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<String> 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;
|
||||
|
||||
+42
-30
@@ -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_<GUID>_*) onto the PDF, so"
|
||||
+ " Purview-aware tools recognise the label. Applies the label only;"
|
||||
+ " it cannot encrypt, which requires the Microsoft client.")
|
||||
public ResponseEntity<Resource> 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<Resource> 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<SensitivityLabel> 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+32
-11
@@ -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<IntegrationConfig, Long> {
|
||||
public class IntegrationConfigRepository implements PanacheRepositoryBase<IntegrationConfig, Long> {
|
||||
|
||||
List<IntegrationConfig> findByOwnerUser(User ownerUser);
|
||||
public List<IntegrationConfig> findByOwnerUser(User ownerUser) {
|
||||
return list("ownerUser", ownerUser);
|
||||
}
|
||||
|
||||
List<IntegrationConfig> findByOwnerTeam(Team ownerTeam);
|
||||
public List<IntegrationConfig> findByOwnerTeam(Team ownerTeam) {
|
||||
return list("ownerTeam", ownerTeam);
|
||||
}
|
||||
|
||||
List<IntegrationConfig> findByScope(OwnerScope scope);
|
||||
public List<IntegrationConfig> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-17
@@ -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<IntegrationConfigValidator> validators;
|
||||
private final List<IntegrationConfigUsageCheck> usageChecks;
|
||||
// Spring List<T>-of-all-beans -> CDI Instance<T>, which is iterable over every bean of a type.
|
||||
private final Instance<IntegrationConfigValidator> validators;
|
||||
private final Instance<IntegrationConfigUsageCheck> 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<String, Object> 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> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -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<TeamCreatedEvent> publisher;
|
||||
|
||||
@Inject
|
||||
void setPublisher(ApplicationEventPublisher applicationEventPublisher) {
|
||||
TeamEntityListener.publisher = applicationEventPublisher;
|
||||
void setPublisher(Event<TeamCreatedEvent> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-18
@@ -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<ClassificationRunBiller> biller;
|
||||
// ObjectProvider.getIfAvailable() -> Instance.isResolvable()/get(); only SaaS supplies a bean.
|
||||
private final Instance<ClassificationRunBiller> biller;
|
||||
|
||||
public ClassificationMeterController(ObjectProvider<ClassificationRunBiller> biller) {
|
||||
// Was a handler argument under Spring MVC; JAX-RS takes the servlet request by injection.
|
||||
private final HttpServletRequest request;
|
||||
|
||||
public ClassificationMeterController(
|
||||
Instance<ClassificationRunBiller> 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<Void> 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. */
|
||||
|
||||
+14
-3
@@ -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 =
|
||||
|
||||
+304
-113
@@ -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<HttpServletRequest> currentRequest;
|
||||
|
||||
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
// Explicit constructor instead of Lombok so the List<PolicyTrigger> 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<PolicyTrigger> policyTriggers,
|
||||
ApplicationProperties applicationProperties,
|
||||
TempFileManager tempFileManager,
|
||||
JobOwnershipService jobOwnershipService,
|
||||
JobStore jobStore,
|
||||
Instance<HttpServletRequest> 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<JobResponse<Void>> 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<PolicyRunView> 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<ToolDiagnostic> 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<Policy> 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<Void> reorderPolicies(@RequestBody List<String> orderedPolicyIds) {
|
||||
public Response reorderPolicies(List<String> 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<Policy> 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<Void> 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<Void> 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<JobResponse<Void>> 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<SweepOutcome> 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<String, Collection<FormValue>> 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<MultipartFile> filesUnder(
|
||||
String partName, Map<String, Collection<FormValue>> parts) {
|
||||
List<MultipartFile> 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<NamedAsset> assetsFrom(Map<String, Collection<FormValue>> 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<NamedAsset> 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<String, Collection<FormValue>> 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<String, Collection<FormValue>> parts) {
|
||||
List<MultipartFile> 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());
|
||||
|
||||
+6
-4
@@ -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);
|
||||
|
||||
+2
@@ -103,6 +103,7 @@ public class PolicyEngine {
|
||||
JobOwnershipService jobOwnershipService,
|
||||
UserService userService,
|
||||
@All List<PolicyOutputSink> 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;
|
||||
}
|
||||
|
||||
+11
-1
@@ -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<InputSource> inputSources) {
|
||||
public PolicyRunner(
|
||||
PolicyEngine policyEngine,
|
||||
@All List<InputSource> 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. */
|
||||
|
||||
+9
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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) {
|
||||
|
||||
+4
-13
@@ -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<ProcessedFileId> {
|
||||
public class ProcessedFileEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -92,15 +90,8 @@ public class ProcessedFileEntity implements Serializable, Persistable<ProcessedF
|
||||
this.updatedAt = nowMillis;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public ProcessedFileId getId() {
|
||||
return new ProcessedFileId(policyId, identityHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public boolean isNew() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+170
-133
@@ -3,10 +3,8 @@ package stirling.software.proprietary.policy.ledger;
|
||||
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;
|
||||
@@ -17,180 +15,219 @@ import jakarta.transaction.Transactional;
|
||||
* Transactional per call so the ledger can run them without an enclosing transaction.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public interface ProcessedFileRepository
|
||||
extends JpaRepository<ProcessedFileEntity, ProcessedFileId> {
|
||||
public class ProcessedFileRepository
|
||||
implements PanacheRepositoryBase<ProcessedFileEntity, ProcessedFileId> {
|
||||
|
||||
/**
|
||||
* 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<ProcessedFileEntity> findByPolicyIdAndIdentityHashIn(
|
||||
String policyId, Collection<String> identityHashes);
|
||||
@Transactional
|
||||
public List<ProcessedFileEntity> findByPolicyIdAndIdentityHashIn(
|
||||
String policyId, Collection<String> 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<String> identityHashes,
|
||||
@Param("now") long now);
|
||||
public int stampSeen(String policyId, Collection<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -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<CompletedMigration, String> {}
|
||||
public class CompletedMigrationRepository
|
||||
implements PanacheRepositoryBase<CompletedMigration, String> {
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+18
-9
@@ -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;
|
||||
|
||||
+6
-7
@@ -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())
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
|
||||
+19
-9
@@ -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<String, IntegrationConfig> indexExistingConnections() {
|
||||
Map<String, IntegrationConfig> byKey = new LinkedHashMap<>();
|
||||
for (IntegrationConfig connection : connections.findAll()) {
|
||||
for (IntegrationConfig connection : connections.listAll()) {
|
||||
if (connection.getIntegrationType() != IntegrationType.S3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+18
-8
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -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.
|
||||
*
|
||||
* <p>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<FolderAccessDeniedException> {
|
||||
|
||||
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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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()));
|
||||
}
|
||||
|
||||
+8
-1
@@ -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<Source> get(String id) {
|
||||
return repository.findById(id).flatMap(this::toSource);
|
||||
return repository.findByIdOptional(id).flatMap(this::toSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public List<Source> all() {
|
||||
return repository.findAll().stream().map(this::toSource).flatMap(Optional::stream).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public List<Source> 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;
|
||||
|
||||
+80
-64
@@ -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<InputSource> inputSources;
|
||||
|
||||
@GetMapping
|
||||
// Explicit constructor instead of Lombok so the List<InputSource> 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<InputSource> 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<Source> 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<List<Long>> 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<Source> 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<Void> 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<String> 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<ProblemDetail> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-16
@@ -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.
|
||||
*
|
||||
* <p>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<SourceDocCountId> {
|
||||
public class SourceDocCountEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -47,16 +47,4 @@ public class SourceDocCountEntity implements Serializable, Persistable<SourceDoc
|
||||
this.bucketHour = bucketHour;
|
||||
this.docCount = docCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public SourceDocCountId getId() {
|
||||
return new SourceDocCountId(sourceId, bucketHour);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public boolean isNew() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+58
-33
@@ -3,32 +3,30 @@ 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 SourceDocCountRepository
|
||||
extends JpaRepository<SourceDocCountEntity, SourceDocCountId> {
|
||||
public class SourceDocCountRepository
|
||||
implements PanacheRepositoryBase<SourceDocCountEntity, SourceDocCountId> {
|
||||
|
||||
/**
|
||||
* 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<SourceDocSum> sumBySourceSince(
|
||||
@Param("ids") Collection<String> ids, @Param("since") long since);
|
||||
public List<SourceDocSum> sumBySourceSince(Collection<String> 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<SourceDayDocSum> dailyCountsSince(
|
||||
@Param("ids") Collection<String> ids, @Param("since") long since);
|
||||
public List<SourceDayDocSum> dailyCountsSince(Collection<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-20
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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<String> {
|
||||
public class SourceDocTotalEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -44,16 +40,4 @@ public class SourceDocTotalEntity implements Serializable, Persistable<String> {
|
||||
this.sourceId = sourceId;
|
||||
this.docTotal = docTotal;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public String getId() {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public boolean isNew() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+31
-15
@@ -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<SourceDocTotalEntity, String> {
|
||||
public class SourceDocTotalRepository
|
||||
implements PanacheRepositoryBase<SourceDocTotalEntity, String> {
|
||||
|
||||
/**
|
||||
* 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<SourceDocSum> totalsFor(@Param("ids") Collection<String> ids);
|
||||
public List<SourceDocSum> totalsFor(Collection<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
|
||||
+28
-8
@@ -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<SourceEntity, String> {
|
||||
public class SourceRepository implements PanacheRepositoryBase<SourceEntity, String> {
|
||||
|
||||
/**
|
||||
* 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<SourceEntity> findByTeam(@Param("teamId") Long teamId);
|
||||
public List<SourceEntity> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -69,10 +69,12 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
||||
PolicyStore policyStore,
|
||||
PolicyRunner policyRunner,
|
||||
@All List<InputSource> inputSources,
|
||||
SourceStore sourceStore,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.policyStore = policyStore;
|
||||
this.policyRunner = policyRunner;
|
||||
this.inputSources = inputSources;
|
||||
this.sourceStore = sourceStore;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
|
||||
+62
-43
@@ -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=<hex>' in the X-Stirling-Signature header. Returns 202 once"
|
||||
+ " the document is spooled for the referencing policies.")
|
||||
public ResponseEntity<WebhookDeliveryResponse> 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) {}
|
||||
}
|
||||
|
||||
+26
-21
@@ -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<String> 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<Map<String, String>> 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<Void> 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+6
-3
@@ -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<Team> 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
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+13
-22
@@ -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();
|
||||
|
||||
|
||||
+2
-6
@@ -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();
|
||||
|
||||
+108
-33
@@ -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}.
|
||||
*
|
||||
* <p>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<ApiKeyDailyUsage, ApiKeyDailyUsageId> {
|
||||
public class ApiKeyDailyUsageRepository
|
||||
implements PanacheRepositoryBase<ApiKeyDailyUsage, ApiKeyDailyUsageId> {
|
||||
|
||||
/** 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<ApiKeyUsageSum> countForDayByIds(
|
||||
@Param("ids") Collection<Long> ids, @Param("epochDay") long epochDay);
|
||||
public List<ApiKeyUsageSum> countForDayByIds(Collection<Long> ids, long epochDay) {
|
||||
List<Object[]> 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<ApiKeyUsageSum> sumSinceByIds(
|
||||
@Param("ids") Collection<Long> ids, @Param("fromDayInclusive") long fromDayInclusive);
|
||||
public List<ApiKeyUsageSum> sumSinceByIds(Collection<Long> ids, long fromDayInclusive) {
|
||||
List<Object[]> 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<ApiKeyDailyUsage> findByApiKeyId(Long apiKeyId);
|
||||
public List<ApiKeyDailyUsage> 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<ApiKeyUsageSum> toSums(List<Object[]> rows) {
|
||||
return rows.stream()
|
||||
.<ApiKeyUsageSum>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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-5
@@ -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<ApiKey, Long> {
|
||||
public class ApiKeyRepository implements PanacheRepositoryBase<ApiKey, Long> {
|
||||
|
||||
Optional<ApiKey> findByKeyHash(String keyHash);
|
||||
public Optional<ApiKey> findByKeyHash(String keyHash) {
|
||||
return find("keyHash", keyHash).firstResultOptional();
|
||||
}
|
||||
|
||||
boolean existsByKeyHash(String keyHash);
|
||||
public boolean existsByKeyHash(String keyHash) {
|
||||
return count("keyHash", keyHash) > 0;
|
||||
}
|
||||
|
||||
List<ApiKey> findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
|
||||
public List<ApiKey> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+19
-5
@@ -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<JwtSigningKeyEntity, String> {
|
||||
public class JwtSigningKeyRepository implements PanacheRepositoryBase<JwtSigningKeyEntity, String> {
|
||||
|
||||
/** Newest first, so the most recently created key is the active signing key. */
|
||||
List<JwtSigningKeyEntity> findAllByOrderByCreatedAtDesc();
|
||||
public List<JwtSigningKeyEntity> findAllByOrderByCreatedAtDesc() {
|
||||
return listAll(Sort.descending("createdAt"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The current active signing key: the single newest row. Used for cheap cluster convergence.
|
||||
*/
|
||||
Optional<JwtSigningKeyEntity> findFirstByOrderByCreatedAtDesc();
|
||||
public Optional<JwtSigningKeyEntity> findFirstByOrderByCreatedAtDesc() {
|
||||
return findAll(Sort.descending("createdAt")).firstResultOptional();
|
||||
}
|
||||
|
||||
/** Keys created before the cutoff, eligible for rotation cleanup. */
|
||||
List<JwtSigningKeyEntity> findByCreatedAtBefore(LocalDateTime cutoff);
|
||||
public List<JwtSigningKeyEntity> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -58,6 +58,16 @@ public class TeamMembershipRepository implements PanacheRepositoryBase<TeamMembe
|
||||
return count("team.id = ?1", teamId);
|
||||
}
|
||||
|
||||
/** Spring Data {@code save}: insert a new row, merge an already-identified one. */
|
||||
@Transactional
|
||||
public TeamMembership save(TeamMembership membership) {
|
||||
if (membership.getMembershipId() == null) {
|
||||
persist(membership);
|
||||
return membership;
|
||||
}
|
||||
return getEntityManager().merge(membership);
|
||||
}
|
||||
|
||||
/** Delete membership by team ID and user ID */
|
||||
@Transactional
|
||||
public void deleteByTeamIdAndUserId(Long teamId, Long userId) {
|
||||
|
||||
-9
@@ -19,29 +19,20 @@ public record CustomSaml2AuthenticatedPrincipal(
|
||||
return this.attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNameId() {
|
||||
return this.nameId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getSessionIndexes() {
|
||||
return this.sessionIndexes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getResponseValue() {
|
||||
return this.responseValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <A> List<A> getAttribute(String name) {
|
||||
List<Object> values = this.attributes.get(name);
|
||||
return values != null ? (List<A>) values : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <A> A getFirstAttribute(String name) {
|
||||
List<Object> values = this.attributes.get(name);
|
||||
|
||||
+15
-3
@@ -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<GrantedAuthority> 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. */
|
||||
|
||||
+2
-5
@@ -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);
|
||||
}
|
||||
|
||||
+20
-17
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+20
-9
@@ -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
|
||||
|
||||
+4
-6
@@ -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());
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
if (cached != null) {
|
||||
return Optional.of(cached);
|
||||
}
|
||||
Optional<JwtSigningKeyEntity> entityOpt = keyRepository.findById(keyId);
|
||||
Optional<JwtSigningKeyEntity> entityOpt = keyRepository.findByIdOptional(keyId);
|
||||
if (entityOpt.isEmpty()) {
|
||||
log.warn("No signing key found in DB for keyId: {}", keyId);
|
||||
return Optional.empty();
|
||||
|
||||
+6
-4
@@ -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<Long> ownerUserIds(Long teamId) {
|
||||
return membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream()
|
||||
.map(row -> row.getUser().getId())
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
|
||||
+7
-3
@@ -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()) {
|
||||
|
||||
+5
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-12
@@ -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<PortalAuditEventRow> 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<PortalAuditEventRow> scopedEvents(String cacheKey, List<String> principals) {
|
||||
@CacheResult(cacheName = CACHE_NAME)
|
||||
public List<PortalAuditEventRow> scopedEvents(
|
||||
@CacheKey String cacheKey, List<String> 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<PortalAuditEventRow> toRows(List<PersistentAuditEvent> events) {
|
||||
|
||||
+5
-2
@@ -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<String> 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");
|
||||
|
||||
+5
@@ -395,6 +395,11 @@ public class EncryptingStorageProvider implements StorageProvider {
|
||||
return contentLength;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOpen() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists() {
|
||||
return true;
|
||||
|
||||
+3
-3
@@ -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: {})",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user