port remaining proprietary sources to quarkus

This commit is contained in:
Anthony Stirling
2026-08-04 09:09:34 +01:00
parent 56a0084864
commit 12b55c2771
119 changed files with 2910 additions and 1555 deletions
+100 -136
View File
@@ -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
-4
View File
@@ -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}"
@@ -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();
}
}
@@ -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());
@@ -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;
}
}
@@ -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 {
@@ -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);
}
}
@@ -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 ----
@@ -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";
@@ -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();
}
}
@@ -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
@@ -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;
@@ -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);
}
}
@@ -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) == '/';
}
}
@@ -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);
}
}
@@ -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();
}
@@ -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;
@@ -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) {
@@ -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");
}
}
@@ -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;
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
@@ -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();
@@ -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());
@@ -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.
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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
@@ -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())));
@@ -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. */
@@ -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. */
@@ -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;
}
}
@@ -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;
@@ -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();
}
/**
@@ -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;
}
}
@@ -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);
}
}
@@ -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()));
}
}
}
@@ -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. */
@@ -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 =
@@ -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());
@@ -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);
@@ -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;
}
@@ -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. */
@@ -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);
}
}
@@ -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) {
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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
@@ -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;
@@ -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())
@@ -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 {
@@ -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;
}
@@ -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 {
@@ -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
@@ -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());
}
@@ -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();
}
}
@@ -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()));
}
@@ -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;
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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 {
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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 {
@@ -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) {}
}
@@ -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/&lt;locale&gt;.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();
}
}
@@ -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
@@ -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
@@ -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;
@@ -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();
@@ -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();
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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) {
@@ -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);
@@ -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. */
@@ -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);
}
@@ -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));
}
}
@@ -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
@@ -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());
@@ -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();
@@ -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())
@@ -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);
}
@@ -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()) {
@@ -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);
}
}
@@ -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) {
@@ -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");
@@ -395,6 +395,11 @@ public class EncryptingStorageProvider implements StorageProvider {
return contentLength;
}
@Override
public boolean isOpen() {
return true;
}
@Override
public boolean exists() {
return true;
@@ -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