Convert proprietary module (security/JPA/oauth2/saml2) to Quarkus via workflow (WIP)

This commit is contained in:
a
2026-06-12 18:26:03 +01:00
parent cec00ab78a
commit 2591faa0ba
206 changed files with 8484 additions and 6178 deletions
@@ -4,46 +4,79 @@ import java.lang.reflect.Method;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.service.AuditService;
/** Aspect for processing {@link Audited} annotations. */
@Aspect
@Component
/**
* Interceptor for processing {@link Audited} annotations.
*
* <p>MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} {@code @Component} with
* {@code @Around("@annotation(...Audited)")} advice. Reworked into a CDI {@link Interceptor} bound
* by the {@code @Audited} annotation; {@code @Around}/{@code ProceedingJoinPoint} became
* {@code @AroundInvoke}/{@link InvocationContext}. Spring's {@code @Order(10)} (lower precedence,
* runs after {@code AutoJobAspect}) maps to {@code @Priority}: {@code AutoJobAspect} uses
* {@code @Priority(20)}, so this audit interceptor uses {@code @Priority(10)} which runs FIRST and
* populates MDC before the job interceptor - matching the original ordering intent (audit captures
* principal/origin/IP on the request thread before the job is dispatched).
*
* <p>TODO: Migration required - the {@code @Audited} annotation
* ({@code stirling.software.proprietary.audit.Audited}) must be made a CDI
* {@code @jakarta.interceptor.InterceptorBinding} (and its members marked
* {@code @jakarta.enterprise.util.Nonbinding}) for this {@code @Interceptor} to bind to it; see the
* already-migrated {@code AutoJobPostMapping}. That is a separate file and is intentionally left
* untouched here.
*
* <p>TODO: Migration required - {@code AuditService}'s helper methods
* ({@code createBaseAuditData}, {@code addFileData}, {@code addMethodArguments},
* {@code resolveEventType}) currently accept an AspectJ {@code ProceedingJoinPoint} /
* {@code joinPoint.getTarget()} / {@code joinPoint.getArgs()}. They must be migrated to accept a CDI
* {@link InvocationContext} (use {@code ctx.getTarget()}, {@code ctx.getParameters()},
* {@code ctx.getMethod()}). The call sites below pass {@code ctx} on that assumption.
*/
@Interceptor
@Audited
@Priority(10)
@Slf4j
@RequiredArgsConstructor
@org.springframework.core.annotation.Order(
10) // Lower precedence (higher number) - executes after AutoJobAspect
public class AuditAspect {
private final AuditService auditService;
private final AuditConfigurationProperties auditConfig;
private final HttpServletRequest request;
private final HttpServletResponse response;
@Around("@annotation(stirling.software.proprietary.audit.Audited)")
public Object auditMethod(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
@Inject
public AuditAspect(
AuditService auditService,
AuditConfigurationProperties auditConfig,
HttpServletRequest request,
HttpServletResponse response) {
this.auditService = auditService;
this.auditConfig = auditConfig;
this.request = request;
this.response = response;
}
@AroundInvoke
public Object auditMethod(InvocationContext ctx) throws Exception {
Method method = ctx.getMethod();
Audited auditedAnnotation = method.getAnnotation(Audited.class);
// Fast path: use unified check to determine if we should audit
// This avoids all data collection if auditing is disabled
if (!auditService.shouldAudit(method, auditConfig)) {
return joinPoint.proceed();
return ctx.proceed();
}
// EARLY CAPTURE: Try to get from MDC first (propagated from background threads)
@@ -60,9 +93,12 @@ public class AuditAspect {
capturedOrigin = auditService.captureCurrentOrigin();
}
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
// MIGRATION: Spring's RequestContextHolder/ServletRequestAttributes -> CDI-injected
// jakarta HttpServletRequest/HttpServletResponse (quarkus-undertow). When invoked outside an
// HTTP request scope the injected proxy resolves to null, so we treat a null request the
// same way the original treated a null ServletRequestAttributes.
HttpServletRequest req = request;
boolean isHttpRequest = req != null;
String capturedIp = MDC.get("auditIp");
if (capturedIp == null) {
@@ -71,15 +107,18 @@ public class AuditAspect {
}
// Only create the map once we know we'll use it
// TODO: Migration required - createBaseAuditData must accept InvocationContext (ctx) once
// AuditService is migrated off ProceedingJoinPoint.
Map<String, Object> auditData =
auditService.createBaseAuditData(joinPoint, auditedAnnotation.level());
auditService.createBaseAuditData(ctx, auditedAnnotation.level());
// Add HTTP information if we're in a web context
if (attrs != null) {
if (isHttpRequest) {
String path = req.getRequestURI();
String httpMethod = req.getMethod();
auditService.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
auditService.addFileData(auditData, joinPoint, auditedAnnotation.level());
// TODO: Migration required - addFileData must accept InvocationContext (ctx).
auditService.addFileData(auditData, ctx, auditedAnnotation.level());
// File operation details logged at DEBUG level for verification
if (auditData.containsKey("files") || auditData.containsKey("filename")) {
@@ -102,7 +141,8 @@ public class AuditAspect {
// Add method arguments if requested (captured at all audit levels for operational context)
if (auditedAnnotation.includeArgs()) {
auditService.addMethodArguments(auditData, joinPoint, auditedAnnotation.level());
// TODO: Migration required - addMethodArguments must accept InvocationContext (ctx).
auditService.addMethodArguments(auditData, ctx, auditedAnnotation.level());
}
// Record start time for latency calculation
@@ -110,7 +150,7 @@ public class AuditAspect {
Object result;
try {
// Execute the method
result = joinPoint.proceed();
result = ctx.proceed();
// Add success status
auditData.put("status", "success");
@@ -126,7 +166,7 @@ public class AuditAspect {
}
return result;
} catch (Throwable ex) {
} catch (Exception ex) {
// Always add failure information regardless of level
auditData.put("status", "failure");
auditData.put("errorType", ex.getClass().getName());
@@ -137,23 +177,24 @@ public class AuditAspect {
} finally {
// Add timing information - use isHttpRequest=false to ensure we get timing for non-HTTP
// methods
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
boolean isHttpRequest = attrs != null;
HttpServletResponse resp = isHttpRequest ? response : null;
auditService.addTimingData(
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
// Resolve the event type based on annotation and context
String httpMethod = null;
String path = null;
if (attrs != null) {
if (isHttpRequest) {
httpMethod = req.getMethod();
path = req.getRequestURI();
}
// TODO: Migration required - resolveEventType reads joinPoint.getTarget(); once
// AuditService is migrated it should use ctx.getTarget().getClass() instead.
AuditEventType eventType =
auditService.resolveEventType(
method,
joinPoint.getTarget().getClass(),
ctx.getTarget().getClass(),
path,
httpMethod,
auditedAnnotation);
@@ -1,9 +1,13 @@
package stirling.software.proprietary.audit;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.HashMap;
import java.util.Map;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.Hidden;
@@ -12,28 +16,42 @@ import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@Controller
@PreAuthorize("hasRole('ADMIN')")
@Path("")
@ApplicationScoped
@RolesAllowed("ADMIN")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardWebController {
private final AuditConfigurationProperties auditConfig;
/** Display the audit dashboard. */
@GetMapping("/audit")
@GET
@Path("/audit")
@Hidden
public String showDashboard(Model model) {
model.addAttribute("auditEnabled", auditConfig.isEnabled());
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
model.addAttribute("auditLevelInt", auditConfig.getLevel());
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
public Response showDashboard() {
// Spring's org.springframework.ui.Model + view-name ("audit/dashboard") drove Thymeleaf
// server-side rendering. Quarkus has no Thymeleaf view resolver; the equivalent is a Qute
// TemplateInstance bound to src/main/resources/templates/audit/dashboard.html.
// TODO: Migration required - rebind this view to Qute. Inject
// @io.quarkus.qute.Location("audit/dashboard") io.quarkus.qute.Template dashboard; and return
// dashboard.data(...) as a TemplateInstance (with a Qute RestEasy extension), or render the
// page client-side. The model attributes below are preserved so they can be passed to the
// Qute template once the audit/dashboard template is ported.
Map<String, Object> model = new HashMap<>();
model.put("auditEnabled", auditConfig.isEnabled());
model.put("auditLevel", auditConfig.getAuditLevel());
model.put("auditLevelInt", auditConfig.getLevel());
model.put("retentionDays", auditConfig.getRetentionDays());
// Add audit level enum values for display
model.addAttribute("auditLevels", AuditLevel.values());
model.put("auditLevels", AuditLevel.values());
// Add audit event types for the dropdown
model.addAttribute("auditEventTypes", AuditEventType.values());
model.put("auditEventTypes", AuditEventType.values());
return "audit/dashboard";
// TODO: Migration required - return the rendered Qute template instead of this placeholder
// once audit/dashboard.html is migrated. The attributes in `model` map 1:1 to the former
// Spring Model attributes.
return Response.ok(model).build();
}
}
@@ -5,91 +5,100 @@ import java.lang.reflect.Method;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.service.AuditService;
/**
* Aspect for automatically auditing controller methods with web mappings (GetMapping, PostMapping,
* etc.)
* Interceptor for automatically auditing controller methods with web mappings.
*
* <p>MIGRATION (Spring AOP -&gt; CDI interceptor): was an {@code @Aspect}/{@code @Component} with
* multiple {@code @Around} advices whose pointcuts matched <em>any</em> method annotated with
* Spring's {@code @GetMapping}/{@code @PostMapping}/{@code @PutMapping}/{@code @DeleteMapping}/
* {@code @PatchMapping}/{@code @AutoJobPostMapping}, plus an {@code execution(...)} expression on
* Spring's {@code ResourceHttpRequestHandler}. {@code @Around}/{@code ProceedingJoinPoint} +
* {@code MethodSignature} became {@code @AroundInvoke}/{@link InvocationContext}, and
* {@code RequestContextHolder}/{@code ServletRequestAttributes} were replaced by an injected
* {@link HttpServletRequest}/{@link HttpServletResponse} (provided by quarkus-undertow). The Spring
* {@code @Order(0)} (highest precedence, runs before {@code AutoJobAspect}) maps to
* {@code @Priority} with a value lower than {@code AutoJobAspect}'s {@code @Priority(20)} so this
* interceptor still populates MDC first.
*
* <p>TODO: Migration required - CDI interceptors are bound by an {@code @InterceptorBinding}
* annotation declared on the target class/method; there is NO CDI equivalent for AspectJ's broad,
* expression-based pointcuts. The original advices fired for every Spring-MVC mapping annotation and
* for the static-resource handler, none of which exist on JAX-RS controllers. To retain
* "audit every HTTP endpoint" behaviour in Quarkus, do ONE of:
* <ul>
* <li>register a JAX-RS {@code @Provider} pair of
* {@code ContainerRequestFilter}/{@code ContainerResponseFilter} (or RESTEasy Reactive
* {@code @ServerRequestFilter}/{@code @ServerResponseFilter}) that calls this same
* {@code AuditService} logic around every resource method (preferred - covers all endpoints
* without per-method annotations); OR
* <li>introduce an explicit {@code @InterceptorBinding} (e.g. {@code @AuditedHttp}) and stamp it on
* the controller classes/methods that should be audited, then bind this interceptor with it.
* </ul>
* As an interim binding this interceptor is bound by the existing {@link AutoJobPostMapping}
* {@code @InterceptorBinding} (one of the six original pointcuts) so the class is valid CDI and
* still audits auto-job POST endpoints. <b>This does NOT cover plain GET/POST/PUT/DELETE/PATCH or
* static-resource requests</b> the way the Spring aspect did - that requires the JAX-RS filter or
* dedicated binding described above. NOTE: it must NOT be bound to {@link Audited}, because the body
* deliberately skips {@code @Audited} methods (those are handled by {@code AuditAspect}).
* The {@code auditController(...)} body below is preserved verbatim; the static-resource and
* static-GET-skip handling (originally driven by the {@code ResourceHttpRequestHandler} pointcut)
* still works via {@link AuditService#isStaticResourceRequest(HttpServletRequest)}.
*/
@Aspect
@Component
@Interceptor
@AutoJobPostMapping
@Priority(0) // Highest precedence - runs BEFORE AutoJobAspect (@Priority(20)) to populate MDC
@Slf4j
@RequiredArgsConstructor
@org.springframework.core.annotation.Order(
0) // Highest precedence - runs BEFORE AutoJobAspect to populate MDC
public class ControllerAuditAspect {
private final AuditService auditService;
private final AuditConfigurationProperties auditConfig;
private final HttpServletRequest request;
private final HttpServletResponse response;
@Around(
"execution(* org.springframework.web.servlet.resource.ResourceHttpRequestHandler.handleRequest(..))")
public Object auditStaticResource(ProceedingJoinPoint jp) throws Throwable {
return auditController(jp, "GET");
@Inject
public ControllerAuditAspect(
AuditService auditService,
AuditConfigurationProperties auditConfig,
HttpServletRequest request,
HttpServletResponse response) {
this.auditService = auditService;
this.auditConfig = auditConfig;
this.request = request;
this.response = response;
}
/** Intercept all methods with GetMapping annotation */
@Around("@annotation(org.springframework.web.bind.annotation.GetMapping)")
public Object auditGetMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "GET");
/**
* TODO: Migration required - this single {@code @AroundInvoke} replaces the five Spring
* {@code @Around} advices (GET/POST/PUT/DELETE/PATCH + AutoJobPostMapping) and the
* static-resource {@code execution(...)} advice. Because CDI cannot inspect Spring/JAX-RS mapping
* annotations to derive the HTTP verb at bind time, the verb is resolved from the live request
* ({@link HttpServletRequest#getMethod()}); if the request is unavailable (non-web invocation) it
* falls back to POST to mirror the most common audited mapping.
*/
@AroundInvoke
public Object auditEndpoint(InvocationContext ctx) throws Throwable {
String httpMethod = request != null ? request.getMethod() : "POST";
return auditController(ctx, httpMethod != null ? httpMethod : "POST");
}
/** Intercept all methods with PostMapping annotation */
@Around("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public Object auditPostMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "POST");
}
/** Intercept all methods with PutMapping annotation */
@Around("@annotation(org.springframework.web.bind.annotation.PutMapping)")
public Object auditPutMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "PUT");
}
/** Intercept all methods with DeleteMapping annotation */
@Around("@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
public Object auditDeleteMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "DELETE");
}
/** Intercept all methods with PatchMapping annotation */
@Around("@annotation(org.springframework.web.bind.annotation.PatchMapping)")
public Object auditPatchMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "PATCH");
}
/** Intercept all methods with AutoJobPostMapping annotation */
@Around("@annotation(stirling.software.common.annotations.AutoJobPostMapping)")
public Object auditAutoJobMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "POST");
}
private Object auditController(ProceedingJoinPoint joinPoint, String httpMethod)
throws Throwable {
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
Method method = sig.getMethod();
private Object auditController(InvocationContext joinPoint, String httpMethod) throws Throwable {
Method method = joinPoint.getMethod();
// Fast path: check if auditing is enabled before doing any work
// This avoids all data collection if auditing is disabled
@@ -123,10 +132,8 @@ public class ControllerAuditAspect {
}
}
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
HttpServletRequest req = request;
HttpServletResponse resp = response;
String previousPrincipal = MDC.get("auditPrincipal");
String previousOrigin = MDC.get("auditOrigin");
@@ -163,6 +170,12 @@ public class ControllerAuditAspect {
long start = System.currentTimeMillis();
// TODO: Migration required (collaborator) - AuditService.createBaseAuditData/addFileData/
// addMethodArguments/resolveEventType still take org.aspectj.lang.ProceedingJoinPoint
// (AuditService is not yet migrated). Once AuditService is converted, change those
// signatures to accept jakarta.interceptor.InvocationContext (getMethod/getParameters/
// getTarget cover the data used). These calls pass the InvocationContext and will only
// typecheck after that collaborator change.
// Use auditService to create the base audit data
Map<String, Object> data = auditService.createBaseAuditData(joinPoint, level);
@@ -255,34 +268,51 @@ public class ControllerAuditAspect {
private String getRequestPath(Method method, String httpMethod) {
// Prefer actual request URI over annotation patterns (which may contain regex)
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest request = attrs.getRequest();
if (request != null) {
return request.getRequestURI();
}
if (request != null) {
return request.getRequestURI();
}
// Fallback: reconstruct from annotations when not in web context
// Fallback: reconstruct from annotations when not in web context.
// TODO: Migration required - the Spring @RequestMapping/@GetMapping/... fallback below relies
// on Spring MVC mapping annotations that no longer exist on JAX-RS controllers. Once the
// controllers are on JAX-RS, switch this fallback to read jakarta.ws.rs.@Path / @GET / @POST
// etc. (or drop it entirely if the request URI is always available). The original Spring
// reconstruction is preserved verbatim until then.
String base = "";
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
org.springframework.web.bind.annotation.RequestMapping cm =
method.getDeclaringClass()
.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
if (cm != null && cm.value().length > 0) base = cm.value()[0];
String mp = "";
Annotation ann =
switch (httpMethod) {
case "GET" -> method.getAnnotation(GetMapping.class);
case "POST" -> method.getAnnotation(PostMapping.class);
case "PUT" -> method.getAnnotation(PutMapping.class);
case "DELETE" -> method.getAnnotation(DeleteMapping.class);
case "PATCH" -> method.getAnnotation(PatchMapping.class);
case "GET" ->
method.getAnnotation(
org.springframework.web.bind.annotation.GetMapping.class);
case "POST" ->
method.getAnnotation(
org.springframework.web.bind.annotation.PostMapping.class);
case "PUT" ->
method.getAnnotation(
org.springframework.web.bind.annotation.PutMapping.class);
case "DELETE" ->
method.getAnnotation(
org.springframework.web.bind.annotation.DeleteMapping.class);
case "PATCH" ->
method.getAnnotation(
org.springframework.web.bind.annotation.PatchMapping.class);
default -> null;
};
if (ann instanceof GetMapping gm && gm.value().length > 0) mp = gm.value()[0];
if (ann instanceof PostMapping pm && pm.value().length > 0) mp = pm.value()[0];
if (ann instanceof PutMapping pum && pum.value().length > 0) mp = pum.value()[0];
if (ann instanceof DeleteMapping dm && dm.value().length > 0) mp = dm.value()[0];
if (ann instanceof PatchMapping pam && pam.value().length > 0) mp = pam.value()[0];
if (ann instanceof org.springframework.web.bind.annotation.GetMapping gm
&& gm.value().length > 0) mp = gm.value()[0];
if (ann instanceof org.springframework.web.bind.annotation.PostMapping pm
&& pm.value().length > 0) mp = pm.value()[0];
if (ann instanceof org.springframework.web.bind.annotation.PutMapping pum
&& pum.value().length > 0) mp = pum.value()[0];
if (ann instanceof org.springframework.web.bind.annotation.DeleteMapping dm
&& dm.value().length > 0) mp = dm.value()[0];
if (ann instanceof org.springframework.web.bind.annotation.PatchMapping pam
&& pam.value().length > 0) mp = pam.value()[0];
return base + mp;
}
@@ -1,11 +1,12 @@
package stirling.software.proprietary.cluster;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import lombok.extern.slf4j.Slf4j;
@@ -13,22 +14,34 @@ import lombok.extern.slf4j.Slf4j;
* Runtime license gate for cluster mode. Cluster mode requires a SERVER or ENTERPRISE license; the
* SaaS flavor bypasses (no {@code runningProOrHigher} bean is published). The Valkey connection
* config {@code @DependsOn} this bean, so it runs before any Valkey bean is constructed.
*
* <p>TODO: Migration required - Spring @DependsOn ordering relative to the Valkey connection config
* has no direct Quarkus equivalent. Ensure the Valkey/Redis bean either @Inject's this gate or that
* this @PostConstruct verification still runs before any Valkey bean is constructed (e.g. via a
* Startup observer ordering or an explicit dependency).
*/
@Configuration
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
@ApplicationScoped
@Slf4j
public class ClusterLicenseGate {
@Autowired(required = false)
@Qualifier("runningProOrHigher")
private Boolean runningProOrHigher;
// @ConditionalOnProperty(name = "cluster.enabled", havingValue = "true") -> runtime guard below.
@ConfigProperty(name = "cluster.enabled", defaultValue = "false")
boolean clusterEnabled;
// @Autowired(required = false) @Qualifier("runningProOrHigher") -> optional named lookup.
@Inject
@Named("runningProOrHigher")
Instance<Boolean> runningProOrHigher;
@PostConstruct
void verifyLicense() {
if (runningProOrHigher == null) {
if (!clusterEnabled) {
return; // cluster mode disabled - gate not applicable
}
if (!runningProOrHigher.isResolvable()) {
return; // saas flavor - licensed via Stripe elsewhere
}
if (!runningProOrHigher) {
if (!runningProOrHigher.get()) {
throw new IllegalStateException(
"Cluster mode (cluster.enabled=true) requires a SERVER or"
+ " ENTERPRISE license. Configure stirling.premium.key with a valid"
@@ -4,8 +4,8 @@ import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
@@ -19,8 +19,12 @@ import stirling.software.common.model.ApplicationProperties;
* Cluster operation metrics exposed via {@code /actuator/prometheus}. Registered only when cluster
* mode is on.
*/
@Component
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
// TODO: Migration required - original @ConditionalOnProperty(name = "cluster.enabled",
// havingValue = "true") was a runtime toggle. Quarkus @IfBuildProfile/@LookupIfProperty are
// build-time only. Either gate registration with a runtime guard on
// applicationProperties.getCluster().isEnabled() (e.g. skip meter registration when disabled),
// or use @io.quarkus.arc.lookup.LookupIfProperty if a build-time switch is acceptable.
@ApplicationScoped
public class ClusterMetrics implements StickyMissRecorder {
private final MeterRegistry registry;
@@ -38,6 +42,7 @@ public class ClusterMetrics implements StickyMissRecorder {
private final AtomicLong jobsInflight = new AtomicLong();
@Inject
public ClusterMetrics(MeterRegistry registry, ApplicationProperties applicationProperties) {
this.registry = registry;
this.applicationProperties = applicationProperties;
@@ -6,13 +6,16 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.runtime.StartupEvent;
import io.quarkus.scheduler.Scheduled;
import lombok.extern.slf4j.Slf4j;
@@ -25,31 +28,49 @@ import stirling.software.common.model.ApplicationProperties.Cluster;
* Registers the local node with {@link InstanceRegistry} on startup, refreshes the entry at 1/3 of
* the TTL, and deregisters cleanly on shutdown.
*
* <p>Implements {@link SmartLifecycle} with {@code getPhase() == Integer.MAX_VALUE} so Spring tears
* this bean down before {@code LettuceConnectionFactory} - deregister therefore runs while the
* Valkey connection is still alive.
* <p>Originally implemented Spring's {@code SmartLifecycle} with {@code getPhase() ==
* Integer.MAX_VALUE} so Spring tore this bean down before {@code LettuceConnectionFactory} -
* deregister therefore ran while the Valkey connection was still alive.
*
* <p>TODO: Migration required - Quarkus has no SmartLifecycle/getPhase shutdown-ordering
* equivalent. Startup now runs via @Observes StartupEvent and shutdown via @PreDestroy. If the
* Quarkus Redis/Valkey client is torn down before this bean's @PreDestroy, the deregister call may
* fail (it already tolerates that via TTL expiry). If strict ordering is required, observe
* io.quarkus.runtime.ShutdownEvent on a bean ordered ahead of the Redis client, or rely on the
* heartbeat TTL to clean up the stale entry.
*/
@Component
@ApplicationScoped
@Slf4j
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
public class ClusterNodeBootstrap implements SmartLifecycle {
public class ClusterNodeBootstrap {
private final Duration heartbeatTtl;
// TODO: Migration required - Spring @ConditionalOnProperty(name = "cluster.enabled",
// havingValue = "true") was a runtime toggle. Quarkus build-time conditionals
// (@IfBuildProfile / @LookupIfProperty) cannot gate a StartupEvent observer at runtime, so the
// bean is always instantiated and the toggle is enforced at runtime via clusterEnabled below.
@ConfigProperty(name = "cluster.enabled", defaultValue = "false")
boolean clusterEnabled;
private Duration heartbeatTtl;
private final ApplicationProperties applicationProperties;
private final InstanceRegistry instanceRegistry;
@Value("${server.port:8080}")
private int serverPort;
@ConfigProperty(name = "server.port", defaultValue = "8080")
int serverPort;
private volatile String nodeId;
private volatile String internalAddress;
private volatile boolean running = false;
@Inject
public ClusterNodeBootstrap(
ApplicationProperties applicationProperties, InstanceRegistry instanceRegistry) {
this.applicationProperties = applicationProperties;
this.instanceRegistry = instanceRegistry;
}
@PostConstruct
void init() {
Cluster cluster = applicationProperties.getCluster();
// Default must match the @Scheduled fallback below AND the model default
// (ApplicationProperties.Cluster.Node.heartbeatIntervalMs = 5000); otherwise the TTL is
@@ -60,18 +81,30 @@ public class ClusterNodeBootstrap implements SmartLifecycle {
this.heartbeatTtl = Duration.ofMillis(heartbeatMs * 3);
}
@EventListener(ApplicationReadyEvent.class)
public void registerOnStartup() {
void registerOnStartup(@Observes StartupEvent event) {
if (!clusterEnabled) {
return;
}
nodeId = applicationProperties.getCluster().resolvedNodeId();
internalAddress = resolveInternalAddress();
running = true;
registerSelf("register");
}
@Scheduled(fixedDelayString = "${cluster.node.heartbeat-interval-ms:5000}")
// TODO: Migration required - Spring @Scheduled(fixedDelayString =
// "${cluster.node.heartbeat-interval-ms:5000}") drove the interval directly from config in
// milliseconds. Quarkus @Scheduled "every" expects a Duration string, so the config reference
// "{cluster.node.heartbeat-interval-ms}" cannot be reused as-is (it resolves to a bare number).
// Hard-coded to 5s to match the model default; if the interval is operator-tunable, expose a
// duration-formatted property (e.g. cluster.node.heartbeat-interval=5s) and reference it here.
@Scheduled(every = "5s")
public void heartbeat() {
// Heartbeat-after-stop race: SmartLifecycle.stop() deregisters, but the @Scheduled
// tick keeps firing during a slow drain. Without this guard, the next tick re-registers
// the dead node and the entry resurfaces in the registry until TTL expiry.
if (!clusterEnabled) {
return;
}
// Heartbeat-after-stop race: shutdown deregisters, but the @Scheduled tick keeps firing
// during a slow drain. Without this guard, the next tick re-registers the dead node and
// the entry resurfaces in the registry until TTL expiry.
if (!running) {
return;
}
@@ -100,13 +133,8 @@ public class ClusterNodeBootstrap implements SmartLifecycle {
}
}
@Override
public void start() {
running = true;
}
@Override
public void stop() {
@PreDestroy
void stop() {
running = false;
if (nodeId == null) {
return;
@@ -124,21 +152,10 @@ public class ClusterNodeBootstrap implements SmartLifecycle {
}
}
@Override
public boolean isRunning() {
return running;
}
@Override
public int getPhase() {
return Integer.MAX_VALUE;
}
@Override
public boolean isAutoStartup() {
return true;
}
/**
* Resolve the address peers should hit. Order: explicit config -> {@code POD_IP} env (K8s
* downward API) -> JDK hostname -> fail loud (never silently fall back to a loopback).
@@ -1,10 +1,12 @@
package stirling.software.proprietary.cluster.s3;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Disposes;
import jakarta.enterprise.inject.Produces;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.lookup.LookupIfProperty;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -13,17 +15,29 @@ import stirling.software.common.cluster.FileStore;
import stirling.software.common.model.ApplicationProperties;
/** Activates the S3-backed transient {@link FileStore} when {@code cluster.artifactStore=s3}. */
// TODO: Migration required - the original Spring class was guarded by
// @ConditionalOnProperty(prefix="cluster", name="artifactStore", havingValue="s3") and
// @ConditionalOnMissingBean on the @Bean. The S3 producer below is gated with
// @io.quarkus.arc.lookup.LookupIfProperty(name="cluster.artifactStore", stringValue="s3"), which
// only contributes this FileStore when the property is "s3"; the always-on @DefaultBean producer in
// common's LocalDiskFileStoreConfiguration covers the "local"/default case, so S3 here wins (a
// non-default producer beats @DefaultBean) only when the property selects it - preserving the
// original @ConditionalOnMissingBean intent. Note: @LookupIfProperty is evaluated at build time, so
// the artifact store cannot be switched at runtime. If a true runtime toggle is required, drop the
// annotation and gate the producer body on the config value instead.
@Slf4j
@Configuration
@ApplicationScoped
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "cluster", name = "artifactStore", havingValue = "s3")
public class S3FileStoreConfiguration {
private final ApplicationProperties applicationProperties;
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public FileStore fileStore(@Value("${cluster.s3.keyPrefix:transient/}") String keyPrefix) {
@Produces
@ApplicationScoped
@LookupIfProperty(name = "cluster.artifactStore", stringValue = "s3")
public FileStore fileStore(
@ConfigProperty(name = "cluster.s3.keyPrefix", defaultValue = "transient/")
String keyPrefix) {
ApplicationProperties.Storage.S3 cfg = applicationProperties.getStorage().getS3();
S3Clients.Bundle bundle = S3Clients.build(cfg, "cluster file store");
// FileStore has no signed-URL contract; close the unused presigner immediately.
@@ -34,4 +48,18 @@ public class S3FileStoreConfiguration {
log.info("Cluster FileStore: s3 (bucket={}, keyPrefix={})", cfg.getBucket(), keyPrefix);
return new S3FileStore(bundle.client(), cfg.getBucket(), keyPrefix, true);
}
/**
* Replaces the Spring {@code @Bean(destroyMethod = "close")} contract: CDI does not auto-invoke
* close() on producer-created beans, so this disposer closes the {@link S3FileStore} when the
* bean is destroyed.
*/
void closeFileStore(@Disposes FileStore fileStore) {
if (fileStore instanceof S3FileStore s3FileStore) {
try {
s3FileStore.close();
} catch (Exception ignored) {
}
}
}
}
@@ -5,15 +5,33 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import io.quarkus.arc.lookup.LookupIfProperty;
/**
* Composite condition: matches only when cluster.enabled=true AND cluster.backplane=valkey. Both
* checks are required (enabled alone may select the in-process backplane, which must not load
* Valkey beans); a single {@code @ConditionalOnExpression} keeps the guard in one place.
* Valkey beans); a single guard keeps the condition in one place.
*
* <p>The original Spring annotation used a single
* {@code @ConditionalOnExpression("${cluster.enabled:false} and
* '${cluster.backplane:inprocess}'.equals('valkey')")} SpEL guard. Quarkus/CDI has no SpEL-based
* conditional, but the boolean AND of two simple property checks maps directly onto two stacked
* (repeatable) {@link LookupIfProperty} annotations, which are evaluated with AND semantics. The
* Valkey producer beans are looked up only when both properties hold; otherwise the
* {@code @DefaultBean} in-process implementations win.
*
* <p>TODO: Migration required - in Spring this was a composite meta-annotation: placing
* {@code @ConditionalOnValkeyBackplane} on a bean transitively applied the underlying
* {@code @ConditionalOnExpression}. Quarkus does NOT transitively propagate {@link LookupIfProperty}
* through a custom meta-annotation, so the two {@code @LookupIfProperty} guards below are documentary
* only - each consumer of this annotation (ValkeyClusterBackplane, ValkeyJobStore,
* ValkeyRateLimitStore, ValkeyDistributedLock, ValkeyKeyValueCache, ValkeyInstanceRegistry) must
* also carry the two {@code @LookupIfProperty} guards directly (or be produced via a producer method
* carrying them). Defaults: cluster.enabled defaults to false and cluster.backplane defaults to
* inprocess, so absent both properties the Valkey beans stay disabled.
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ConditionalOnExpression(
"${cluster.enabled:false} and '${cluster.backplane:inprocess}'.equals('valkey')")
@LookupIfProperty(name = "cluster.enabled", stringValue = "true")
@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
public @interface ConditionalOnValkeyBackplane {}
@@ -1,32 +1,47 @@
package stirling.software.proprietary.cluster.valkey;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.quarkus.redis.datasource.RedisDataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
@ApplicationScoped
// TODO: Migration required - @ConditionalOnValkeyBackplane was a Spring @ConditionalOnExpression
// guard ("cluster.enabled=true AND cluster.backplane=valkey"). Quarkus has no runtime
// @Conditional for beans; this bean is now always instantiated. Gate selection at runtime
// (e.g. a ClusterBackplane producer that picks valkey vs in-process based on injected config),
// or use @io.quarkus.arc.lookup.LookupIfProperty(name="cluster.backplane", stringValue="valkey")
// (build-time/static only - does not also check cluster.enabled). The composite condition must be
// re-expressed accordingly.
public class ValkeyClusterBackplane implements ClusterBackplane {
private final ApplicationProperties applicationProperties;
private final StringRedisTemplate template;
@Inject
ApplicationProperties applicationProperties;
// TODO: Migration required - was Spring spring-data-redis StringRedisTemplate. Replaced with
// Quarkus RedisDataSource (io.quarkus.redis.datasource). Verify the redis client extension
// (quarkus-redis-client) is on the classpath and configured via quarkus.redis.* properties.
@Inject
RedisDataSource redisDataSource;
@Override
public boolean isHealthy() {
try {
// template.execute() borrows from the pool and returns the connection in a finally
// block - critical because isHealthy() is hit on every k8s liveness/readiness probe
// tick. Calling getConnectionFactory().getConnection() directly leaks the connection
// and exhausts the pool under monitoring load.
String pong = template.execute((RedisCallback<String>) connection -> connection.ping());
// Original used template.execute() so the connection was borrowed from the pool and
// returned in a finally block - critical because isHealthy() is hit on every k8s
// liveness/readiness probe tick. Quarkus RedisDataSource manages connection
// pooling/return internally, so issuing a single command (PING) is the equivalent.
// TODO: Migration required - confirm command mapping. Quarkus exposes PING via the
// low-level command API: redisDataSource.execute("PING") returns a Response whose
// toString() is the simple-string reply "PONG". Validate this against the actual
// RedisDataSource API version in use.
String pong = redisDataSource.execute("PING").toString();
return "PONG".equalsIgnoreCase(pong);
} catch (RuntimeException ex) {
log.warn("Valkey backplane health check failed: {}", ex.getMessage());
@@ -4,10 +4,6 @@ import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
@@ -18,23 +14,55 @@ import org.springframework.data.redis.core.StringRedisTemplate;
import io.lettuce.core.RedisCommandExecutionException;
import io.lettuce.core.SslVerifyMode;
import io.quarkus.arc.lookup.LookupIfProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Named;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
// TODO: Migration required - this class still depends on spring-data-redis types
// (LettuceConnectionFactory, StringRedisTemplate, RedisStandaloneConfiguration,
// LettuceClientConfiguration, RedisPassword, RedisConnection). Quarkus has no spring-data-redis;
// the backplane should be reworked onto io.quarkus.redis.datasource.RedisDataSource /
// ReactiveRedisDataSource configured via quarkus.redis.* in application.properties (hosts, password,
// tls, timeout=2s). The produced beans below are consumed by ValkeyClusterBackplane and the other
// Valkey* collaborators in this package; migrating this file requires migrating those consumers in
// lockstep, so the spring-data-redis imports are retained until that coordinated change lands. The
// pure URL-parsing / handshake / auth-detection helpers (parseUrl, buildClientConfiguration,
// eagerHandshake, isAuthFailure) are framework-agnostic and carry over unchanged.
//
// DI/config mapping applied here:
// @Configuration -> @ApplicationScoped (producer bean class)
// @Bean -> @Produces (+ @Named for the StringRedisTemplate)
// @ConditionalOnProperty(cluster.enabled)-> @LookupIfProperty(name="cluster.enabled", stringValue="true")
// @ConditionalOnProperty(backplane=valkey)-> @LookupIfProperty(name="cluster.backplane", stringValue="valkey")
// @DependsOn("clusterLicenseGate") -> TODO: ordering; ensure clusterLicenseGate runs first
// (CDI has no @DependsOn; use @Observes ordering or an
// explicit @Inject of the gate bean once migrated).
// @Bean(destroyMethod="destroy") -> @PreDestroy on the produced instance is not expressible
// on a @Produces method here; rely on factory.destroy()
// already wired via Spring's destroy lifecycle until the
// RedisDataSource migration removes this bean. TODO.
@Slf4j
@Configuration
@ApplicationScoped
@RequiredArgsConstructor
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
@DependsOn("clusterLicenseGate")
@LookupIfProperty(name = "cluster.enabled", stringValue = "true")
public class ValkeyConnectionConfiguration {
private final ApplicationProperties applicationProperties;
@Bean(destroyMethod = "destroy")
@ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey")
// TODO: Migration required - replace LettuceConnectionFactory with a configured
// io.quarkus.redis.datasource.RedisDataSource (quarkus.redis.* config). destroyMethod="destroy"
// has no @Produces equivalent without a @Disposes method; keep factory.destroy() lifecycle until
// the RedisDataSource migration.
@Produces
@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
public LettuceConnectionFactory valkeyConnectionFactory() {
Cluster cluster = applicationProperties.getCluster();
Endpoint endpoint = parseUrl(cluster.getValkey().getUrl());
@@ -257,8 +285,12 @@ public class ValkeyConnectionConfiguration {
return t.getMessage();
}
@Bean
@ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey")
// TODO: Migration required - StringRedisTemplate is spring-data-redis. Once the connection
// migrates to RedisDataSource, this producer should be removed and consumers should inject the
// Quarkus RedisDataSource (string commands via redisDataSource.value(String.class)) directly.
@Produces
@Named("valkeyTemplate")
@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
public StringRedisTemplate valkeyTemplate(LettuceConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
@@ -8,16 +8,36 @@ import java.util.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import io.quarkus.arc.lookup.LookupIfProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.DistributedLock;
@Component
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
// DI mapping applied here:
// @Component -> @ApplicationScoped
// @RequiredArgsConstructor -> explicit @Inject constructor (single injected collaborator)
// @ConditionalOnValkeyBackplane -> the two stacked @LookupIfProperty guards below (per the note in
// ConditionalOnValkeyBackplane: Quarkus does not transitively
// propagate @LookupIfProperty through the meta-annotation, so the
// guards are repeated directly on this consumer).
//
// TODO: Migration required - this class still depends on spring-data-redis types
// (StringRedisTemplate, RedisScript, DefaultRedisScript). Quarkus has no spring-data-redis; once
// ValkeyConnectionConfiguration migrates its producer onto io.quarkus.redis.datasource.RedisDataSource,
// this lock should be reworked to use RedisDataSource: SET NX PX for tryAcquire and EVAL of the
// release/renew Lua scripts (redisDataSource.execute("EVAL", script, "1", key, value[, ttlMillis])).
// The injected bean is the @Named("valkeyTemplate") StringRedisTemplate produced there, so this file
// and that producer must migrate in lockstep; the spring-data-redis imports are retained until then.
// The Lua scripts and the acquire/release/renew control flow are framework-agnostic and carry over.
@ApplicationScoped
@LookupIfProperty(name = "cluster.enabled", stringValue = "true")
@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
@Slf4j
public class ValkeyDistributedLock implements DistributedLock {
@@ -35,6 +55,11 @@ public class ValkeyDistributedLock implements DistributedLock {
private final StringRedisTemplate template;
@Inject
public ValkeyDistributedLock(@Named("valkeyTemplate") StringRedisTemplate template) {
this.template = template;
}
@Override
public Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime) {
String key = PREFIX + lockKey;
@@ -1,6 +1,5 @@
package stirling.software.proprietary.cluster.valkey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
@@ -10,11 +9,14 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.hash.HashCommands;
import io.quarkus.redis.datasource.keys.KeyCommands;
import io.quarkus.redis.datasource.keys.KeyScanCursor;
import io.quarkus.redis.datasource.keys.ScanArgs;
import io.quarkus.redis.datasource.transactions.TransactionResult;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
@@ -25,14 +27,17 @@ import stirling.software.common.cluster.InstanceRegistry;
* Valkey-backed {@link InstanceRegistry}. Each node is stored as a hash with a TTL equal to the
* configured heartbeat TTL; the heartbeat re-arms the TTL.
*/
@Component
// TODO: Migration required - the original @ConditionalOnValkeyBackplane (cluster.enabled=true AND
// cluster.backplane=valkey) was a runtime toggle. Quarkus build-time conditions (@IfBuildProfile /
// @LookupIfProperty) cannot express this composite runtime expression. Guard producer/usage at
// runtime via the Config values, or rework ConditionalOnValkeyBackplane into a CDI lookup guard.
@ApplicationScoped
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
public class ValkeyInstanceRegistry implements InstanceRegistry {
private static final String PREFIX = "stirling:nodes:";
private final StringRedisTemplate template;
private final RedisDataSource redis;
@Override
public void register(ClusterNode node, Duration heartbeatTtl) {
@@ -47,22 +52,14 @@ public class ValkeyInstanceRegistry implements InstanceRegistry {
// MULTI/EXEC so the hash fields and the TTL commit together. Without this, a crash
// between HSET and EXPIRE leaves the hash with no TTL: it never expires, masks the
// dead node as alive, and only a subsequent successful register() would re-arm it.
template.execute(
(RedisCallback<Object>)
connection -> {
connection.multi();
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
Map<byte[], byte[]> hashBytes = new LinkedHashMap<>();
for (Map.Entry<String, String> f : fields.entrySet()) {
hashBytes.put(
f.getKey().getBytes(StandardCharsets.UTF_8),
f.getValue().getBytes(StandardCharsets.UTF_8));
}
connection.hashCommands().hMSet(keyBytes, hashBytes);
connection.keyCommands().pExpire(keyBytes, ttlMs);
connection.exec();
return null;
TransactionResult result =
redis.withTransaction(
tx -> {
tx.hash(String.class).hset(key, fields);
tx.key(String.class).pexpire(key, ttlMs);
});
// result.discarded() would be true if the transaction was aborted; the heartbeat will
// re-arm on the next register() so we do not fail hard here.
}
@Override
@@ -72,11 +69,13 @@ public class ValkeyInstanceRegistry implements InstanceRegistry {
@Override
public Collection<ClusterNode> activeNodes() {
ScanOptions options = ScanOptions.scanOptions().match(PREFIX + "*").count(256).build();
List<ClusterNode> nodes = new ArrayList<>();
try (Cursor<String> cursor = template.scan(options)) {
while (cursor.hasNext()) {
readNode(cursor.next()).ifPresent(nodes::add);
KeyCommands<String> keys = redis.key(String.class);
KeyScanCursor<String> cursor =
keys.scan(new ScanArgs().match(PREFIX + "*").count(256));
while (cursor.hasNext()) {
for (String key : cursor.next()) {
readNode(key).ifPresent(nodes::add);
}
}
return nodes;
@@ -84,32 +83,33 @@ public class ValkeyInstanceRegistry implements InstanceRegistry {
@Override
public void deregister(String nodeId) {
template.delete(PREFIX + nodeId);
redis.key(String.class).del(PREFIX + nodeId);
}
private Optional<ClusterNode> readNode(String key) {
Map<Object, Object> entries = template.opsForHash().entries(key);
HashCommands<String, String, String> hash = redis.hash(String.class);
Map<String, String> entries = hash.hgetall(key);
if (entries == null || entries.isEmpty()) {
return Optional.empty();
}
Object nodeId = entries.get("nodeId");
String nodeId = entries.get("nodeId");
if (nodeId == null) {
return Optional.empty();
}
Instant heartbeat = Instant.now();
Object hb = entries.get("lastHeartbeat");
String hb = entries.get("lastHeartbeat");
if (hb != null) {
try {
heartbeat = Instant.parse(hb.toString());
heartbeat = Instant.parse(hb);
} catch (RuntimeException ignored) {
// keep default
}
}
return Optional.of(
new ClusterNode(
nodeId.toString(),
String.valueOf(entries.getOrDefault("internalAddress", "")),
nodeId,
entries.getOrDefault("internalAddress", ""),
heartbeat,
String.valueOf(entries.getOrDefault("role", "BOTH"))));
entries.getOrDefault("role", "BOTH")));
}
}
@@ -11,17 +11,22 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.hash.HashCommands;
import io.quarkus.redis.datasource.keys.KeyCommands;
import io.quarkus.redis.datasource.keys.KeyScanArgs;
import io.quarkus.redis.datasource.keys.KeyScanCursor;
import io.quarkus.redis.datasource.transactions.OptimisticLockingTransactionResult;
import io.quarkus.redis.datasource.transactions.TransactionResult;
import io.quarkus.redis.datasource.value.SetArgs;
import io.quarkus.redis.datasource.value.ValueCommands;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.JobStore;
@@ -31,11 +36,16 @@ import stirling.software.common.cluster.JobStoreEntry;
* Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId.
*
* <p><b>put() atomicity:</b> the hash fields, the per-job TTL, and the reverse-index entries are
* issued inside a single pipelined Redis transaction (MULTI/EXEC). A partial failure cannot leave
* the hash without a TTL or with half the file→job index entries written.
* issued inside a single Redis transaction (MULTI/EXEC). A partial failure cannot leave the hash
* without a TTL or with half the file->job index entries written.
*/
@Component
@RequiredArgsConstructor
// TODO: Migration required - @ConditionalOnValkeyBackplane (Spring @ConditionalOnExpression) is a
// runtime toggle on cluster.enabled + cluster.backplane=valkey. Quarkus has no direct equivalent
// for the composite expression; either reimplement ConditionalOnValkeyBackplane as a Quarkus
// build-time condition (@io.quarkus.arc.profile.IfBuildProfile /
// @io.quarkus.arc.lookup.LookupIfProperty) or guard bean activation at runtime. Annotation left in
// place pending that collaborator change.
@ApplicationScoped
@ConditionalOnValkeyBackplane
@Slf4j
public class ValkeyJobStore implements JobStore {
@@ -47,7 +57,21 @@ public class ValkeyJobStore implements JobStore {
private static final TypeReference<List<String>> LIST_STRING = new TypeReference<>() {};
private static final TypeReference<Map<String, String>> MAP_STRING = new TypeReference<>() {};
private final StringRedisTemplate template;
// String-keyed, byte-valued command groups mirror the original byte-level access so JSON
// payloads and ids round-trip exactly as they did via StringRedisTemplate's byte commands.
private final RedisDataSource redis;
private final HashCommands<String, String, byte[]> hash;
private final ValueCommands<String, byte[]> value;
private final KeyCommands<String> keys;
private final ValueCommands<String, String> stringValue;
public ValkeyJobStore(RedisDataSource redis) {
this.redis = redis;
this.hash = redis.hash(String.class, String.class, byte[].class);
this.value = redis.value(String.class, byte[].class);
this.keys = redis.key(String.class);
this.stringValue = redis.value(String.class, String.class);
}
@Override
public void put(JobStoreEntry entry, Duration ttl) {
@@ -71,37 +95,30 @@ public class ValkeyJobStore implements JobStore {
"resultMeta",
writeJson(entry.resultMeta() == null ? Map.of() : entry.resultMeta()));
// Build pipelined MULTI/EXEC so the hash, its TTL, and every reverse-index entry
// commit atomically.
template.execute(
(RedisCallback<Object>)
connection -> {
connection.multi();
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
Map<byte[], byte[]> hashBytes = new LinkedHashMap<>();
for (Map.Entry<String, String> f : fields.entrySet()) {
hashBytes.put(
f.getKey().getBytes(StandardCharsets.UTF_8),
f.getValue().getBytes(StandardCharsets.UTF_8));
}
connection.hashCommands().hMSet(keyBytes, hashBytes);
connection.keyCommands().pExpire(keyBytes, ttlMs);
if (entry.fileIds() != null) {
for (String fileId : entry.fileIds()) {
byte[] idxKey =
(FILE_INDEX_PREFIX + fileId)
.getBytes(StandardCharsets.UTF_8);
connection
.stringCommands()
.set(
idxKey,
entry.jobId().getBytes(StandardCharsets.UTF_8));
connection.keyCommands().pExpire(idxKey, ttlMs);
}
}
connection.exec();
return null;
});
Map<String, byte[]> hashBytes = new LinkedHashMap<>();
for (Map.Entry<String, String> f : fields.entrySet()) {
hashBytes.put(f.getKey(), f.getValue().getBytes(StandardCharsets.UTF_8));
}
// Build MULTI/EXEC so the hash, its TTL, and every reverse-index entry commit atomically.
// Quarkus' withTransaction enqueues commands issued on the transactional datasource between
// MULTI and EXEC. SetArgs.px(ttl) sets the value-with-TTL in one SET (the original issued a
// separate pExpire after SET); pexpire keeps the original two-step shape for the hash.
redis.withTransaction(
tx -> {
tx.hash(String.class, String.class, byte[].class).hset(key, hashBytes);
tx.key(String.class).pexpire(key, ttlMs);
if (entry.fileIds() != null) {
for (String fileId : entry.fileIds()) {
String idxKey = FILE_INDEX_PREFIX + fileId;
tx.value(String.class, byte[].class)
.set(
idxKey,
entry.jobId().getBytes(StandardCharsets.UTF_8),
new SetArgs().px(ttlMs));
}
}
});
}
@Override
@@ -118,54 +135,40 @@ public class ValkeyJobStore implements JobStore {
// case; further contention falls through to lazy TTL cleanup (acceptable - this is an
// eviction path, not a correctness primitive).
String jobKey = JOB_PREFIX + jobId;
byte[] jobKeyBytes = jobKey.getBytes(StandardCharsets.UTF_8);
for (int attempt = 0; attempt < 2; attempt++) {
Boolean committed =
template.execute(
(RedisCallback<Boolean>)
connection -> {
connection.watch(jobKeyBytes);
// Read the single fileIds field with hGet rather than
// hGetAll + map.get: hGetAll returns a Map<byte[],byte[]>
// whose keys compare by identity, so a fresh
// "fileIds".getBytes() lookup never matches and the reverse
// index would be left orphaned. hGet resolves the field
// server-side.
byte[] fileIdsBytes =
connection
.hashCommands()
.hGet(
jobKeyBytes,
"fileIds"
.getBytes(
StandardCharsets
.UTF_8));
List<byte[]> keysToDelete = new ArrayList<>();
keysToDelete.add(jobKeyBytes);
if (fileIdsBytes != null) {
List<String> fileIds =
readJsonList(
new String(
fileIdsBytes,
StandardCharsets.UTF_8),
jobKey);
for (String fileId : fileIds) {
keysToDelete.add(
(FILE_INDEX_PREFIX + fileId)
.getBytes(StandardCharsets.UTF_8));
}
}
connection.multi();
for (byte[] key : keysToDelete) {
connection.keyCommands().del(key);
}
List<Object> results = connection.exec();
// exec() returns null when WATCH detected a concurrent
// write; spring-data-redis surfaces this as either null
// or empty depending on the driver path.
return results != null && !results.isEmpty();
});
if (Boolean.TRUE.equals(committed)) {
// withTransaction(preTxBlock, watchedKeys...): the preTxBlock runs after WATCH and
// before MULTI; its result feeds the transactional block. If a watched key changes
// before EXEC, Quarkus aborts and the result reports discarded() == true.
OptimisticLockingTransactionResult<List<String>> result =
redis.withTransaction(
ds -> {
// Read the single fileIds field with hget rather than hgetall:
// resolve the field server-side and avoid byte[]-key identity
// pitfalls when looking it back up client-side.
byte[] fileIdsBytes =
ds.hash(String.class, String.class, byte[].class)
.hget(jobKey, "fileIds");
if (fileIdsBytes == null) {
return List.<String>of();
}
return readJsonList(
new String(fileIdsBytes, StandardCharsets.UTF_8), jobKey);
},
tx -> {
List<String> fileIds = tx.getPreTransactionResult();
List<String> keysToDelete = new ArrayList<>();
keysToDelete.add(jobKey);
for (String fileId : fileIds) {
keysToDelete.add(FILE_INDEX_PREFIX + fileId);
}
tx.key(String.class)
.del(keysToDelete.toArray(new String[0]));
},
jobKey);
TransactionResult txResult = result.getExecutionResult();
// EXEC returns null (discarded) when WATCH detected a concurrent write; Quarkus
// surfaces this as discarded() == true.
if (txResult != null && !txResult.discarded()) {
return;
}
}
@@ -177,34 +180,40 @@ public class ValkeyJobStore implements JobStore {
@Override
public boolean exists(String jobId) {
Boolean exists = template.hasKey(JOB_PREFIX + jobId);
return Boolean.TRUE.equals(exists);
return keys.exists(JOB_PREFIX + jobId);
}
@Override
public Optional<String> findJobIdByFileId(String fileId) {
return Optional.ofNullable(template.opsForValue().get(FILE_INDEX_PREFIX + fileId));
return Optional.ofNullable(stringValue.get(FILE_INDEX_PREFIX + fileId));
}
@Override
public Collection<JobStoreEntry> all() {
// SCAN, not KEYS - KEYS blocks the Valkey server for the duration of the walk.
ScanOptions options = ScanOptions.scanOptions().match(JOB_PREFIX + "*").count(256).build();
KeyScanCursor<String> cursor =
keys.scan(new KeyScanArgs().match(JOB_PREFIX + "*").count(256));
List<JobStoreEntry> result = new ArrayList<>();
try (Cursor<String> cursor = template.scan(options)) {
while (cursor.hasNext()) {
readEntry(cursor.next()).ifPresent(result::add);
while (cursor.hasNext()) {
for (String key : cursor.next()) {
readEntry(key).ifPresent(result::add);
}
}
return result;
}
private Optional<JobStoreEntry> readEntry(String key) {
Map<Object, Object> entries = template.opsForHash().entries(key);
if (entries == null || entries.isEmpty()) {
Map<String, byte[]> raw = hash.hgetall(key);
if (raw == null || raw.isEmpty()) {
return Optional.empty();
}
Object jobId = entries.get("jobId");
Map<String, String> entries = new HashMap<>();
for (Map.Entry<String, byte[]> e : raw.entrySet()) {
entries.put(
e.getKey(),
e.getValue() == null ? null : new String(e.getValue(), StandardCharsets.UTF_8));
}
String jobId = entries.get("jobId");
if (jobId == null) {
return Optional.empty();
}
@@ -223,10 +232,10 @@ public class ValkeyJobStore implements JobStore {
state = JobStoreEntry.JobState.PENDING;
}
String owningNodeId = String.valueOf(entries.getOrDefault("owningNodeId", ""));
String error = entries.get("error") == null ? null : entries.get("error").toString();
String error = entries.get("error") == null ? null : entries.get("error");
return Optional.of(
new JobStoreEntry(
jobId.toString(),
jobId,
state,
owningNodeId,
createdAt,
@@ -4,54 +4,60 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.keys.KeyScanArgs;
import io.quarkus.redis.datasource.keys.KeyScanCursor;
import io.quarkus.redis.datasource.value.SetArgs;
import io.quarkus.redis.datasource.value.ValueCommands;
import lombok.RequiredArgsConstructor;
import jakarta.enterprise.context.ApplicationScoped;
import stirling.software.common.cluster.KeyValueCache;
@Component
@RequiredArgsConstructor
@ConditionalOnValkeyBackplane
// TODO: Migration required - @ConditionalOnValkeyBackplane (a Spring @ConditionalOnExpression
// composite on cluster.enabled + cluster.backplane=valkey) has no direct CDI equivalent. Once that
// collaborator annotation is migrated, re-guard this bean (e.g. @io.quarkus.arc.lookup.LookupIfProperty
// or @io.quarkus.arc.profile.IfBuildProfile, or a runtime guard) so Valkey beans only load when
// cluster.enabled=true AND cluster.backplane=valkey.
@ApplicationScoped
public class ValkeyKeyValueCache implements KeyValueCache {
private static final String PREFIX = "stirling:kv:";
private final StringRedisTemplate template;
private final RedisDataSource redis;
private final ValueCommands<String, String> values;
public ValkeyKeyValueCache(RedisDataSource redis) {
this.redis = redis;
this.values = redis.value(String.class, String.class);
}
@Override
public void put(String namespace, String key, String value, Duration ttl) {
template.opsForValue()
.set(buildKey(namespace, key), value, ttl.toMillis(), TimeUnit.MILLISECONDS);
values.set(buildKey(namespace, key), value, new SetArgs().px(ttl.toMillis()));
}
@Override
public Optional<String> get(String namespace, String key) {
return Optional.ofNullable(template.opsForValue().get(buildKey(namespace, key)));
return Optional.ofNullable(values.get(buildKey(namespace, key)));
}
@Override
public void evict(String namespace, String key) {
template.delete(buildKey(namespace, key));
redis.key(String.class).del(buildKey(namespace, key));
}
@Override
public void evictNamespace(String namespace) {
ScanOptions options =
ScanOptions.scanOptions().match(PREFIX + namespace + ":*").count(256).build();
KeyScanArgs options = new KeyScanArgs().match(PREFIX + namespace + ":*").count(256);
List<String> keys = new ArrayList<>();
try (Cursor<String> cursor = template.scan(options)) {
while (cursor.hasNext()) {
keys.add(cursor.next());
}
KeyScanCursor<String> cursor = redis.key(String.class).scan(options);
while (cursor.hasNext()) {
keys.addAll(cursor.next());
}
if (!keys.isEmpty()) {
template.delete(keys);
redis.key(String.class).del(keys.toArray(new String[0]));
}
}
@@ -3,8 +3,13 @@ package stirling.software.proprietary.cluster.valkey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
// TODO: Migration required - LettuceConnectionFactory is a spring-data-redis type produced by the
// not-yet-migrated ValkeyConnectionConfiguration collaborator. This store only needs the raw
// io.lettuce.core.RedisClient that Bucket4j's Lettuce ProxyManager builds on. Once
// ValkeyConnectionConfiguration is migrated to a Quarkus producer, switch this injection point to a
// produced io.lettuce.core.RedisClient (or io.quarkus.redis.datasource.RedisDataSource) and delete
// the getNativeClient() unwrap in initProxyManager(). Kept for now so the Bucket4j logic stays intact.
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.stereotype.Component;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.ConsumptionProbe;
@@ -14,9 +19,12 @@ import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
import io.lettuce.core.AbstractRedisClient;
import io.lettuce.core.RedisClient;
import io.quarkus.arc.lookup.LookupIfProperty;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import stirling.software.common.cluster.RateLimitStore;
@@ -25,8 +33,13 @@ import stirling.software.common.cluster.RateLimitStore;
* refills continuously and enforces one global limit across nodes, with the same semantics as the
* in-process {@code InProcessRateLimitStore} (which also uses Bucket4j).
*/
@Component
// @ConditionalOnValkeyBackplane is documentary only under CDI (see that annotation's javadoc);
// the two guards below must be carried directly so the Valkey beans load only when
// cluster.enabled=true AND cluster.backplane=valkey, otherwise the in-process @DefaultBean wins.
@ApplicationScoped
@ConditionalOnValkeyBackplane
@LookupIfProperty(name = "cluster.enabled", stringValue = "true")
@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey")
public class ValkeyRateLimitStore implements RateLimitStore {
private static final String PREFIX = "stirling:rl:";
@@ -34,6 +47,7 @@ public class ValkeyRateLimitStore implements RateLimitStore {
private final LettuceConnectionFactory connectionFactory;
private ProxyManager<byte[]> proxyManager;
@Inject
public ValkeyRateLimitStore(LettuceConnectionFactory connectionFactory) {
this.connectionFactory = connectionFactory;
}
@@ -4,58 +4,60 @@ import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.core.task.support.TaskExecutorAdapter;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Named;
@Configuration
@EnableAsync
import org.slf4j.MDC;
@ApplicationScoped
public class AsyncConfig {
/**
* MDC context-propagating task decorator. Copies MDC context from the caller thread to the
* virtual thread executing the task.
* Wraps a delegate {@link Executor} so that the caller thread's MDC context is propagated to the
* worker (virtual) thread executing the task, then cleared afterwards to avoid leaks.
*/
static class MDCContextTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// Capture the MDC context from the current thread
static Executor mdcPropagating(Executor delegate) {
return command -> {
// Capture the MDC context from the current (caller) thread
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
// Set the captured context on the worker thread
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
// Execute the task
runnable.run();
} finally {
// Clear the context to prevent memory leaks
MDC.clear();
}
};
}
delegate.execute(
() -> {
try {
// Set the captured context on the worker thread
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
// Execute the task
command.run();
} finally {
// Clear the context to prevent memory leaks
MDC.clear();
}
});
};
}
@Bean(name = "auditExecutor")
@Produces
@Named("auditExecutor")
@ApplicationScoped
public Executor auditExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return adapter;
return mdcPropagating(Executors.newVirtualThreadPerTaskExecutor());
}
/** Propagates the request's SecurityContext onto background AI-orchestration threads. */
@Bean(name = "aiStreamExecutor")
@Produces
@Named("aiStreamExecutor")
@ApplicationScoped
public Executor aiStreamExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return new DelegatingSecurityContextExecutor(adapter);
// TODO: Migration required - this previously wrapped the executor in Spring Security's
// DelegatingSecurityContextExecutor to propagate the SecurityContext onto background
// threads. Quarkus has no direct equivalent; the SecurityIdentity must be captured on the
// caller thread and re-established on the worker thread (e.g. via a captured
// io.quarkus.security.identity.SecurityIdentity or
// org.eclipse.microprofile.context.ThreadContext from MicroProfile Context Propagation).
// For now only MDC context is propagated; security context propagation is NOT preserved.
return mdcPropagating(Executors.newVirtualThreadPerTaskExecutor());
}
}
@@ -1,8 +1,7 @@
package stirling.software.proprietary.config;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -14,10 +13,12 @@ import stirling.software.proprietary.audit.AuditLevel;
* Configuration properties for the audit system. Reads values from the ApplicationProperties under
* premium.enterpriseFeatures.audit
*/
// TODO: Migration required - Spring @Order(HIGHEST_PRECEDENCE + 10) had no direct CDI
// equivalent; bean ordering/precedence must be handled via @Priority or explicit ordering at
// injection points if it was relied upon.
@Slf4j
@Getter
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
@ApplicationScoped
public class AuditConfigurationProperties {
private final boolean enabled;
@@ -27,6 +28,7 @@ public class AuditConfigurationProperties {
private final boolean capturePdfAuthor;
private final boolean captureOperationResults;
@Inject
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
applicationProperties.getPremium().getEnterpriseFeatures().getAudit();
@@ -1,12 +1,12 @@
package stirling.software.proprietary.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import jakarta.enterprise.context.ApplicationScoped;
/** Configuration for audit system transaction management. */
@Configuration
@EnableTransactionManagement
public class AuditJpaConfig {
// Scheduling is enabled on SPDFApplication — no duplicate @EnableScheduling needed.
// JPA repositories are now managed by DatabaseConfig to avoid conflicts.
}
// TODO: Migration required - Quarkus enables transaction management automatically
// (Narayana/JTA via quarkus-narayana-jta); the Spring @EnableTransactionManagement is
// not needed. Use jakarta.transaction.@Transactional on methods/beans as required.
// Scheduling is enabled on the application — no duplicate @EnableScheduling needed.
// JPA repositories are auto-discovered by Quarkus (no @EnableJpaRepositories needed).
@ApplicationScoped
public class AuditJpaConfig {}
@@ -4,13 +4,9 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import jakarta.enterprise.context.ApplicationScoped;
import org.slf4j.MDC;
import org.springframework.boot.actuate.audit.AuditEvent;
import org.springframework.boot.actuate.audit.AuditEventRepository;
import org.springframework.context.annotation.Primary;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,30 +17,32 @@ import stirling.software.proprietary.util.SecretMasker;
import tools.jackson.databind.ObjectMapper;
@Component
@Primary
// TODO: Migration required - this class implemented Spring Boot Actuator's
// org.springframework.boot.actuate.audit.AuditEventRepository (with @Primary). Quarkus has no
// Actuator equivalent, so the interface and the org.springframework.boot.actuate.audit.AuditEvent
// type are gone. The write side has been ported to a plain CDI bean that accepts the audit data
// directly (see add(...) below). Whatever Spring code previously published AuditEvents to this
// repository must be updated to call this bean's add(...) method (or an equivalent producer) once
// the audit-publishing pipeline is migrated. The read-side find(...) was intentionally inert
// (endpoint disabled) and has been dropped.
@ApplicationScoped
@RequiredArgsConstructor
@Slf4j
public class CustomAuditEventRepository implements AuditEventRepository {
public class CustomAuditEventRepository {
private final PersistentAuditEventRepository repo;
private final ObjectMapper mapper;
/* ── READ side intentionally inert (endpoint disabled) ── */
@Override
public List<AuditEvent> find(String p, Instant after, String type) {
return List.of();
}
/* ── WRITE side (async) ───────────────────────────────── */
@Async("auditExecutor")
@Override
public void add(AuditEvent ev) {
/* ── WRITE side ───────────────────────────────────────── */
// TODO: Migration required - was @Async("auditExecutor") (Spring async executor). Quarkus has
// no @Async; run this off the request thread via a managed executor (e.g. inject
// org.eclipse.microprofile.context.ManagedExecutor and submit, or annotate with
// @io.smallrye.common.annotation.Blocking on a reactive path). Logic is kept synchronous for
// now to avoid changing behavior incorrectly.
public void add(String principal, String type, Instant timestamp, Map<String, Object> data) {
try {
Map<String, Object> clean =
CollectionUtils.isEmpty(ev.getData())
? Map.of()
: SecretMasker.mask(ev.getData());
(data == null || data.isEmpty()) ? Map.of() : SecretMasker.mask(data);
if (clean.isEmpty() || (clean.size() == 1 && clean.containsKey("details"))) {
return;
@@ -61,17 +59,24 @@ public class CustomAuditEventRepository implements AuditEventRepository {
PersistentAuditEvent ent =
PersistentAuditEvent.builder()
.principal(ev.getPrincipal())
.type(ev.getType())
.principal(principal)
.type(type)
.data(auditEventData)
.timestamp(ev.getTimestamp())
.timestamp(timestamp)
.build();
// TODO: Migration required - repo.save(...) depends on PersistentAuditEventRepository
// being migrated to a Quarkus PanacheRepository (save -> persist). Update this call
// once that collaborator is converted.
repo.save(ent);
} catch (Exception e) {
log.error(
"Failed to persist audit event (fail-open); principal={}",
ev.getPrincipal(),
e);
log.error("Failed to persist audit event (fail-open); principal={}", principal, e);
}
}
/* ── READ side intentionally inert (endpoint disabled) ──
* Original find(String, Instant, String) returned List.of(); the Actuator read endpoint was
* disabled. Re-add a typed read method here if an audit-query endpoint is reintroduced. */
public List<PersistentAuditEvent> find() {
return List.of();
}
}
@@ -1,23 +1,23 @@
package stirling.software.proprietary.configuration;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import io.quarkus.runtime.StartupEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.ServerCertificateServiceInterface;
@Component
@ApplicationScoped
@RequiredArgsConstructor
@Slf4j
public class ServerCertificateInitializer {
private final ServerCertificateServiceInterface serverCertificateService;
@EventListener(ApplicationReadyEvent.class)
public void initializeServerCertificate() {
public void initializeServerCertificate(@Observes StartupEvent event) {
try {
serverCertificateService.initializeServerCertificate();
} catch (Exception e) {
@@ -2,12 +2,12 @@ package stirling.software.proprietary.controller.api;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -23,11 +23,11 @@ import stirling.software.common.service.TaskManager;
* Admin controller for job management. These endpoints require admin privileges and provide insight
* into system jobs and queues.
*/
@RestController
@ApplicationScoped
@Path("/api/v1/admin")
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ADMIN')")
@RolesAllowed("ADMIN")
@Tag(name = "Admin Job Management", description = "Admin-only Job Management APIs")
public class AdminJobController {
@@ -39,16 +39,17 @@ public class AdminJobController {
*
* @return Job statistics
*/
@GetMapping("/job/stats")
@GET
@Path("/job/stats")
@Operation(summary = "Get job statistics")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<JobStats> getJobStats() {
@RolesAllowed("ADMIN")
public Response getJobStats() {
JobStats stats = taskManager.getJobStats();
log.info(
"Admin requested job stats: {} active, {} completed jobs",
stats.getActiveJobs(),
stats.getCompletedJobs());
return ResponseEntity.ok(stats);
return Response.ok(stats).build();
}
/**
@@ -56,13 +57,14 @@ public class AdminJobController {
*
* @return Queue statistics
*/
@GetMapping("/job/queue/stats")
@GET
@Path("/job/queue/stats")
@Operation(summary = "Get job queue statistics")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> getQueueStats() {
@RolesAllowed("ADMIN")
public Response getQueueStats() {
Map<String, Object> queueStats = jobQueue.getQueueStats();
log.info("Admin requested queue stats: {} queued jobs", queueStats.get("queuedJobs"));
return ResponseEntity.ok(queueStats);
return Response.ok(queueStats).build();
}
/**
@@ -70,10 +72,11 @@ public class AdminJobController {
*
* @return A response indicating how many jobs were cleaned up
*/
@PostMapping("/job/cleanup")
@POST
@Path("/job/cleanup")
@Operation(summary = "Cleanup old jobs")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> cleanupOldJobs() {
@RolesAllowed("ADMIN")
public Response cleanupOldJobs() {
int beforeCount = taskManager.getJobStats().getTotalJobs();
taskManager.cleanupOldJobs();
int afterCount = taskManager.getJobStats().getTotalJobs();
@@ -84,10 +87,11 @@ public class AdminJobController {
removedCount,
afterCount);
return ResponseEntity.ok(
Map.of(
"message", "Cleanup complete",
"removedJobs", removedCount,
"remainingJobs", afterCount));
return Response.ok(
Map.of(
"message", "Cleanup complete",
"removedJobs", removedCount,
"remainingJobs", afterCount))
.build();
}
}
@@ -5,26 +5,28 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.eclipse.microprofile.config.inject.ConfigProperty;
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.inject.Named;
import jakarta.validation.Valid;
import jakarta.ws.rs.BeanParam;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
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.extern.slf4j.Slf4j;
@@ -47,8 +49,8 @@ import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
@Slf4j
@RestController
@RequestMapping("/api/v1/ai")
@ApplicationScoped
@jakarta.ws.rs.Path("/api/v1/ai")
@Hidden
@Tag(name = "AI Engine", description = "Endpoints for AI-powered PDF workflows")
public class AiEngineController {
@@ -60,25 +62,31 @@ public class AiEngineController {
private final TaskManager taskManager;
private final JobOwnershipService jobOwnershipService;
private final AiEngineEndpointResolver endpointResolver;
private final UserServiceInterface userService;
private final Instance<UserServiceInterface> userService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
*
* <p>TODO: Migration required - the JAX-RS SSE API has no per-emitter timeout equivalent to
* Spring's {@code SseEmitter} constructor argument. Enforce this timeout against the background
* orchestration task (e.g. a scheduled cancellation / Future.get with timeout) if a hard cap is
* required; for now it only drives the timeout error frame's wording.
*/
@Value("${stirling.ai.streamTimeoutMs:1800000}")
private long streamTimeoutMs;
@ConfigProperty(name = "stirling.ai.streamTimeoutMs", defaultValue = "1800000")
long streamTimeoutMs;
@Inject
public AiEngineController(
AiEngineClient aiEngineClient,
AiWorkflowService aiWorkflowService,
ObjectMapper objectMapper,
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
@Named("aiStreamExecutor") Executor aiStreamExecutor,
TaskManager taskManager,
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver,
@Autowired(required = false) UserServiceInterface userService) {
Instance<UserServiceInterface> userService) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
this.objectMapper = objectMapper;
@@ -90,71 +98,70 @@ public class AiEngineController {
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
return userService.isResolvable() ? userService.get().getCurrentUsername() : null;
}
@GetMapping("/health")
@GET
@jakarta.ws.rs.Path("/health")
@Operation(
summary = "AI engine health check",
description = "Returns the health status of the AI engine including configured models")
public ResponseEntity<String> health() throws IOException {
public Response health() throws IOException {
String response = aiEngineClient.get("/health", currentUserId());
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
return Response.ok(response, MediaType.APPLICATION_JSON).build();
}
@PostMapping(value = "/orchestrate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@POST
@jakarta.ws.rs.Path("/orchestrate")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "Run an AI workflow against a PDF",
description =
"Accepts PDF uploads and a user message and returns an AI workflow result."
+ " When the workflow produces files, they are registered with the job"
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
// TODO: Migration required - @BeanParam multipart binding depends on collaborator changes:
// AiWorkflowRequest / AiWorkflowFileInput must have their multipart fields annotated with
// @org.jboss.resteasy.reactive.RestForm and the nested AiWorkflowFileInput.fileInput must be
// ported off Spring's MultipartFile to FileUpload + FileUploadMultipartFile.of(...). Until then
// RESTEasy Reactive cannot populate this request from the multipart form body.
public AiWorkflowResponse orchestrate(@Valid @BeanParam AiWorkflowRequest request)
throws IOException {
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
registerFileResultAsJob(result);
return result;
}
@PostMapping(value = "/orchestrate/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@POST
@jakarta.ws.rs.Path("/orchestrate/stream")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@org.jboss.resteasy.reactive.RestStreamElementType(MediaType.APPLICATION_JSON)
@Operation(
summary = "Run an AI workflow with streaming progress",
description =
"Accepts a PDF upload and a user message, returns SSE events with progress"
+ " updates followed by the final AI workflow result")
public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
emitter.onTimeout(
() -> {
// Emit an explicit error frame so the frontend reports a timeout rather than
// silently seeing the stream end without a result.
log.warn(
"SSE emitter timed out for AI orchestration stream after {} ms",
streamTimeoutMs);
sendEvent(
emitter,
"error",
Map.of(
"message",
"AI workflow timed out after "
+ (streamTimeoutMs / 1000)
+ " seconds"));
emitter.complete();
});
emitter.onError(e -> log.warn("SSE emitter error for AI orchestration stream", e));
aiStreamExecutor.execute(() -> runOrchestrationStream(request, emitter));
return emitter;
// TODO: Migration required - same @BeanParam multipart binding dependency as orchestrate():
// AiWorkflowRequest / AiWorkflowFileInput need @RestForm fields and a FileUpload-based file
// model before RESTEasy Reactive can bind this request from the multipart body.
public void orchestrateStream(
@Valid @BeanParam AiWorkflowRequest request,
@Context Sse sse,
@Context SseEventSink sink) {
// The JAX-RS SseEventSink replaces Spring's SseEmitter. There is no onTimeout/onError
// callback registration; sink.send(...) returns a CompletionStage and a disconnected
// client surfaces as a failed send / closed sink, which the orchestration loop detects
// via ClientDisconnectedException below.
aiStreamExecutor.execute(() -> runOrchestrationStream(request, sse, sink));
}
private void runOrchestrationStream(AiWorkflowRequest request, SseEmitter emitter) {
private void runOrchestrationStream(
AiWorkflowRequest request, Sse sse, SseEventSink sink) {
AiWorkflowService.ProgressListener listener =
new AiWorkflowService.ProgressListener() {
@Override
public void onProgress(AiWorkflowProgressEvent event) {
sendEvent(emitter, "progress", event);
sendEvent(sse, sink, "progress", event);
}
@Override
@@ -163,26 +170,27 @@ public class AiEngineController {
// real progress events; if the frontend has gone away, sendEvent throws,
// which propagates up through the stream consumer and closes our upstream
// engine connection so the engine can cancel its in-flight workflow.
sendEvent(emitter, "heartbeat", Map.of());
sendEvent(sse, sink, "heartbeat", Map.of());
}
};
try {
AiWorkflowResponse result = aiWorkflowService.orchestrate(request, listener);
registerFileResultAsJob(result);
sendEvent(emitter, "result", result);
emitter.complete();
sendEvent(sse, sink, "result", result);
sink.close();
} catch (ClientDisconnectedException e) {
// The frontend gave up mid-stream. The exception unwinding through orchestrate()
// already closed the upstream engine connection (engine sees disconnect and cancels).
// The emitter is already toast; nothing useful left to send.
// The sink is already toast; nothing useful left to send.
log.debug("Client disconnected mid-stream; aborting workflow", e);
} catch (Exception e) {
log.error("AI orchestration stream failed", e);
// Emit an error frame for the frontend and then complete normally. Using
// completeWithError here as well would double-complete the emitter - the error
// Emit an error frame for the frontend and then complete normally. The error
// frame already conveys the failure to the client.
sendEvent(emitter, "error", Map.of("message", e.getMessage()));
emitter.complete();
sendEvent(sse, sink, "error", Map.of("message", e.getMessage()));
if (!sink.isClosed()) {
sink.close();
}
}
}
@@ -217,21 +225,30 @@ public class AiEngineController {
taskManager.setComplete(jobKey);
}
private void sendEvent(SseEmitter emitter, String name, Object data) {
private void sendEvent(Sse sse, SseEventSink sink, String name, Object data) {
if (sink.isClosed()) {
throw new ClientDisconnectedException("Client disconnected from SSE stream", null);
}
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 e) {
// Surface the disconnect so the streaming pipeline unwinds: callers higher up close
// the upstream engine connection, which lets the engine cancel its in-flight workflow.
// Without this, the engine would keep producing (and billing for) tokens whose results
// nobody is reading.
// CompletionStage join surfaces a delivery failure (client gone) synchronously so the
// streaming pipeline unwinds: callers higher up close the upstream engine connection,
// which lets the engine cancel its in-flight workflow. Without this, the engine would
// keep producing (and billing for) tokens whose results nobody is reading.
sink.send(event).toCompletableFuture().join();
} catch (RuntimeException e) {
throw new ClientDisconnectedException("Client disconnected from SSE stream", e);
}
}
/**
* Thrown by {@link #sendEvent} when the SSE emitter's underlying connection is gone. Treated as
* a signal to abort the workflow, not as an error to report.
* Thrown by {@link #sendEvent} when the SSE sink's underlying connection is gone. Treated as a
* signal to abort the workflow, not as an error to report.
*/
private static final class ClientDisconnectedException extends RuntimeException {
ClientDisconnectedException(String message, Throwable cause) {
@@ -239,29 +256,31 @@ public class AiEngineController {
}
}
@PostMapping(value = "/pdf/edit", consumes = MediaType.APPLICATION_JSON_VALUE)
@POST
@jakarta.ws.rs.Path("/pdf/edit")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(
summary = "Generate a PDF edit plan",
description =
"Sends a user message to the PDF edit agent which returns a structured plan"
+ " of tool operations to perform")
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
public Response pdfEdit(String requestBody) throws IOException {
JsonNode parsed = parseJson(requestBody);
if (!parsed.isObject()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
throw new WebApplicationException(
"Request body must be a JSON object", Response.Status.BAD_REQUEST);
}
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody, currentUserId());
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
return Response.ok(response, MediaType.APPLICATION_JSON).build();
}
private JsonNode parseJson(String body) {
try {
return objectMapper.readValue(body, JsonNode.class);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
throw new WebApplicationException(
"Request body is not valid JSON", Response.Status.BAD_REQUEST);
}
}
@@ -13,21 +13,17 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.BeanParam;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -36,6 +32,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.model.api.audit.AuditDataRequest;
import stirling.software.proprietary.model.api.audit.AuditDataResponse;
@@ -50,9 +49,9 @@ import tools.jackson.databind.ObjectMapper;
/** REST endpoints for the audit dashboard. */
@Slf4j
@RestController
@RequestMapping("/api/v1/audit")
@PreAuthorize("hasRole('ADMIN')")
@ApplicationScoped
@jakarta.ws.rs.Path("/api/v1/audit")
@RolesAllowed("ADMIN")
@RequiredArgsConstructor
@EnterpriseEndpoint
@Tag(name = "Audit", description = "Only Enterprise - Audit related operations")
@@ -62,14 +61,20 @@ public class AuditDashboardController {
private final ObjectMapper objectMapper;
/** Get audit events data for the dashboard tables. */
@GetMapping("/data")
@GET
@jakarta.ws.rs.Path("/data")
@Operation(summary = "Get audit events data")
public AuditDataResponse getAuditData(@ParameterObject AuditDataRequest request) {
public AuditDataResponse getAuditData(@BeanParam AuditDataRequest request) {
Pageable pageable =
PageRequest.of(
request.getPage(), request.getSize(), Sort.by("timestamp").descending());
Page<PersistentAuditEvent> events;
// TODO: Migration required - PersistentAuditEventRepository is a collaborator that must be
// migrated to io.quarkus.hibernate.orm.panache.PanacheRepositoryBase<PersistentAuditEvent,
// Long>. Its paged finders should return io.quarkus.panache.common.PanacheQuery (or apply
// the Page/Sort built here) instead of org.springframework.data.domain.Page. The pagination
// request below is expressed with Panache Page/Sort; once the repository accepts these the
// .page(...)/.list()/.count()/.pageCount() calls used here will resolve.
Page page = Page.of(request.getPage(), request.getSize());
Sort sort = Sort.by("timestamp", Sort.Direction.Descending);
io.quarkus.hibernate.orm.panache.PanacheQuery<PersistentAuditEvent> query;
String type = request.getType();
String principal = request.getPrincipal();
@@ -79,49 +84,52 @@ public class AuditDashboardController {
if (type != null && principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
query =
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
principal, type, start, end, pageable);
principal, type, start, end, page, sort);
} else if (type != null && principal != null) {
events = auditRepository.findByPrincipalAndType(principal, type, pageable);
query = auditRepository.findByPrincipalAndType(principal, type, page, sort);
} else if (type != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable);
query = auditRepository.findByTypeAndTimestampBetween(type, start, end, page, sort);
} else if (principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
query =
auditRepository.findByPrincipalAndTimestampBetween(
principal, start, end, pageable);
principal, start, end, page, sort);
} else if (startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTimestampBetween(start, end, pageable);
query = auditRepository.findByTimestampBetween(start, end, page, sort);
} else if (type != null) {
events = auditRepository.findByType(type, pageable);
query = auditRepository.findByType(type, page, sort);
} else if (principal != null) {
events = auditRepository.findByPrincipal(principal, pageable);
query = auditRepository.findByPrincipal(principal, page, sort);
} else {
events = auditRepository.findAll(pageable);
query = auditRepository.findAll(sort).page(page);
}
// Logging
List<PersistentAuditEvent> content = events.getContent();
List<PersistentAuditEvent> content = query.list();
return new AuditDataResponse(
content, events.getTotalPages(), events.getTotalElements(), events.getNumber());
content, query.pageCount(), query.count(), query.page().index);
}
/** Get statistics for charts (last X days). Existing behavior preserved. */
@GetMapping("/stats")
@GET
@jakarta.ws.rs.Path("/stats")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get audit statistics for the last N days")
public AuditStatsResponse getAuditStats(
@Schema(
description = "Number of days to look back for audit events",
example = "7",
requiredMode = Schema.RequiredMode.REQUIRED)
@RequestParam(value = "days", defaultValue = "7")
@QueryParam("days")
@DefaultValue("7")
int days) {
// Get events from the last X days
@@ -158,9 +166,10 @@ public class AuditDashboardController {
}
// /** Advanced statistics using repository aggregations, with explicit date range. */
// @GetMapping("/stats/range")
// @GET
// @Path("/stats/range")
// @Operation(summary = "Get audit statistics for a date range (aggregated in DB)")
// public Map<String, Object> getAuditStatsRange(@ParameterObject AuditDateExportRequest
// public Map<String, Object> getAuditStatsRange(@BeanParam AuditDateExportRequest
// request) {
// LocalDate startDate = request.getStartDate();
@@ -199,7 +208,9 @@ public class AuditDashboardController {
// }
/** Get all unique event types from the database for filtering. */
@GetMapping("/types")
@GET
@jakarta.ws.rs.Path("/types")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get all unique audit event types")
public List<String> getAuditTypes() {
// Get distinct event types from the database
@@ -220,9 +231,10 @@ public class AuditDashboardController {
}
/** Export audit data as CSV. */
@GetMapping("/export/csv")
@GET
@jakarta.ws.rs.Path("/export/csv")
@Operation(summary = "Export audit data as CSV")
public ResponseEntity<byte[]> exportAuditData(@ParameterObject AuditExportRequest request) {
public Response exportAuditData(@BeanParam AuditExportRequest request) {
List<PersistentAuditEvent> events = getAuditEventsByCriteria(request);
@@ -243,17 +255,19 @@ public class AuditDashboardController {
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
// Set up HTTP headers for download
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDispositionFormData("attachment", "audit_export.csv");
return ResponseEntity.ok().headers(headers).body(csvBytes);
return Response.ok(csvBytes)
.type(MediaType.APPLICATION_OCTET_STREAM)
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\"audit_export.csv\"")
.build();
}
/** Export audit data as JSON. */
@GetMapping("/export/json")
@GET
@jakarta.ws.rs.Path("/export/json")
@Operation(summary = "Export audit data as JSON")
public ResponseEntity<byte[]> exportAuditDataJson(@ParameterObject AuditExportRequest request) {
public Response exportAuditDataJson(@BeanParam AuditExportRequest request) {
List<PersistentAuditEvent> events = getAuditEventsByCriteria(request);
@@ -262,66 +276,71 @@ public class AuditDashboardController {
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
// Set up HTTP headers for download
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setContentDispositionFormData("attachment", "audit_export.json");
return ResponseEntity.ok().headers(headers).body(jsonBytes);
return Response.ok(jsonBytes)
.type(MediaType.APPLICATION_JSON)
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\"audit_export.json\"")
.build();
} catch (JacksonException e) {
log.error("Error serializing audit events to JSON", e);
return ResponseEntity.internalServerError().build();
return Response.serverError().build();
}
}
// /** Get all unique principals. */
// @GetMapping("/principals")
// @GET
// @Path("/principals")
// @Operation(summary = "Get all distinct principals")
// public List<String> getPrincipals() {
// return auditRepository.findDistinctPrincipals();
// }
// /** Get principals by event type. */
// @GetMapping("/types/{type}/principals")
// @GET
// @Path("/types/{type}/principals")
// @Operation(summary = "Get distinct principals for a given type")
// public List<String> getPrincipalsByType(@PathVariable("type") String type) {
// public List<String> getPrincipalsByType(@PathParam("type") String type) {
// return auditRepository.findDistinctPrincipalsByType(type);
// }
// /** Latest helpers */
// @GetMapping("/latest")
// @GET
// @Path("/latest")
// @Operation(summary = "Get the latest audit event, optionally filtered by type or principal")
// public ResponseEntity<PersistentAuditEvent> getLatest(
// @RequestParam(value = "type", required = false) String type,
// @RequestParam(value = "principal", required = false) String principal) {
// public Response getLatest(
// @QueryParam("type") String type,
// @QueryParam("principal") String principal) {
// if (type != null) {
// return auditRepository
// .findTopByTypeOrderByTimestampDesc(type)
// .map(ResponseEntity::ok)
// .orElse(ResponseEntity.noContent().build());
// .map(e -> Response.ok(e).build())
// .orElse(Response.noContent().build());
// } else if (principal != null) {
// return auditRepository
// .findTopByPrincipalOrderByTimestampDesc(principal)
// .map(ResponseEntity::ok)
// .orElse(ResponseEntity.noContent().build());
// .map(e -> Response.ok(e).build())
// .orElse(Response.noContent().build());
// }
// return auditRepository
// .findTopByOrderByTimestampDesc()
// .map(ResponseEntity::ok)
// .orElse(ResponseEntity.noContent().build());
// .map(e -> Response.ok(e).build())
// .orElse(Response.noContent().build());
// }
/** Cleanup endpoints data before a certain date */
@DeleteMapping("/cleanup/before")
@DELETE
@jakarta.ws.rs.Path("/cleanup/before")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Cleanup audit events before a certain date",
description = "Deletes all audit events before the specified date.")
public Map<String, Object> cleanupBefore(
@RequestParam(value = "date", required = true)
@QueryParam("date")
@Schema(
description = "The cutoff date for cleanup",
example = "2025-01-01",
format = "date")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate date) {
if (date != null && !date.isAfter(LocalDate.now())) {
Instant cutoff = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
@@ -390,7 +409,7 @@ public class AuditDashboardController {
} else if (principal != null) {
events = auditRepository.findAllByPrincipalForExport(principal);
} else {
events = auditRepository.findAll();
events = auditRepository.listAll();
}
return events;
}
@@ -9,18 +9,25 @@ import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
// TODO: Migration required - PersistentAuditEventRepository is a not-yet-migrated collaborator
// (Spring Data JPA, task: Code: Spring Data JPA -> Hibernate ORM Panache). It still returns Spring
// org.springframework.data.domain.Page and accepts Pageable. These four Spring Data imports must
// stay until that repository is ported to PanacheRepositoryBase. Once it is, replace Pageable with
// io.quarkus.panache.common.Page, Sort.by("timestamp").descending() with
// io.quarkus.panache.common.Sort.descending("timestamp"), and Page<...> with PanacheQuery<...>.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -36,8 +43,12 @@ import tools.jackson.databind.ObjectMapper;
/** REST API controller for audit data used by React frontend. */
@Slf4j
@ApplicationScoped
// @ProprietaryUiDataApi carries only the OpenAPI @Tag; JAX-RS does not inherit @Path from
// meta-annotations, so the path is declared explicitly here.
@jakarta.ws.rs.Path("/api/v1/proprietary/ui-data")
@ProprietaryUiDataApi
@PreAuthorize("hasRole('ADMIN')")
@RolesAllowed("ADMIN")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditRestController {
@@ -51,33 +62,32 @@ public class AuditRestController {
*
* @param page Page number (0-indexed)
* @param pageSize Number of items per page
* @param eventType Filter by event type(s) - can be single value or array
* @param username Filter by username(s) - can be single value or array
* @param startDate Filter start date
* @param endDate Filter end date
* @param eventTypes Filter by event type(s) - can be single value or array
* @param usernames Filter by username(s) - can be single value or array
* @param startDateStr Filter start date (ISO yyyy-MM-dd)
* @param endDateStr Filter end date (ISO yyyy-MM-dd)
* @return Paginated audit events response
*/
@GetMapping("/audit-events")
public ResponseEntity<AuditEventsResponse> getAuditEvents(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
@RequestParam(value = "eventType", required = false) String[] eventTypes,
@RequestParam(value = "username", required = false) String[] usernames,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate endDate) {
@GET
@jakarta.ws.rs.Path("/audit-events")
public Response getAuditEvents(
@QueryParam("page") @jakarta.ws.rs.DefaultValue("0") int page,
@QueryParam("pageSize") @jakarta.ws.rs.DefaultValue("30") int pageSize,
@QueryParam("eventType") List<String> eventTypes,
@QueryParam("username") List<String> usernames,
@QueryParam("startDate") String startDateStr,
@QueryParam("endDate") String endDateStr) {
LocalDate startDate = parseIsoDate(startDateStr);
LocalDate endDate = parseIsoDate(endDateStr);
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
Page<PersistentAuditEvent> events;
// Convert arrays to lists
List<String> eventTypeList =
(eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
List<String> usernameList =
(usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
(eventTypes != null && !eventTypes.isEmpty()) ? eventTypes : null;
List<String> usernameList = (usernames != null && !usernames.isEmpty()) ? usernames : null;
Instant startInstant = null;
Instant endInstant = null;
@@ -129,7 +139,7 @@ public class AuditRestController {
.totalPages(events.getTotalPages())
.build();
return ResponseEntity.ok(response);
return Response.ok(response).build();
}
/**
@@ -138,9 +148,10 @@ public class AuditRestController {
* @param period Time period for charts (day/week/month)
* @return Chart data for events by type, user, and over time
*/
@GetMapping("/audit-charts")
public ResponseEntity<AuditChartsData> getAuditCharts(
@RequestParam(value = "period", defaultValue = "week") String period) {
@GET
@jakarta.ws.rs.Path("/audit-charts")
public Response getAuditCharts(
@QueryParam("period") @jakarta.ws.rs.DefaultValue("week") String period) {
// Calculate days based on period
int days;
@@ -224,7 +235,7 @@ public class AuditRestController {
.eventsOverTime(eventsOverTimeChart)
.build();
return ResponseEntity.ok(chartsData);
return Response.ok(chartsData).build();
}
/**
@@ -232,8 +243,9 @@ public class AuditRestController {
*
* @return List of unique event types
*/
@GetMapping("/audit-event-types")
public ResponseEntity<List<String>> getEventTypes() {
@GET
@jakarta.ws.rs.Path("/audit-event-types")
public Response getEventTypes() {
// Get distinct event types from the database
List<String> dbTypes = auditRepository.findDistinctEventTypes();
@@ -250,7 +262,7 @@ public class AuditRestController {
List<String> result = combinedTypes.stream().sorted().collect(Collectors.toList());
return ResponseEntity.ok(result);
return Response.ok(result).build();
}
/**
@@ -258,8 +270,9 @@ public class AuditRestController {
*
* @return List of unique usernames
*/
@GetMapping("/audit-users")
public ResponseEntity<List<String>> getUsers() {
@GET
@jakarta.ws.rs.Path("/audit-users")
public Response getUsers() {
// Use the countByPrincipal query to get unique principals
List<Object[]> principalCounts = auditRepository.countByPrincipal();
@@ -269,7 +282,7 @@ public class AuditRestController {
.sorted()
.collect(Collectors.toList());
return ResponseEntity.ok(users);
return Response.ok(users).build();
}
/**
@@ -279,9 +292,10 @@ public class AuditRestController {
* @param period Time period for statistics (day/week/month)
* @return Audit statistics data for dashboard KPI cards and enhanced charts
*/
@GetMapping("/audit-stats")
public ResponseEntity<AuditStatsData> getAuditStats(
@RequestParam(value = "period", defaultValue = "week") String period) {
@GET
@jakarta.ws.rs.Path("/audit-stats")
public Response getAuditStats(
@QueryParam("period") @jakarta.ws.rs.DefaultValue("week") String period) {
// Calculate days based on period
int days;
@@ -323,24 +337,25 @@ public class AuditRestController {
hourlyDistribution.put(String.format("%02d", hour), count);
}
return ResponseEntity.ok(
AuditStatsData.builder()
.totalEvents(currentMetrics.totalEvents)
.prevTotalEvents(prevMetrics.totalEvents)
.uniqueUsers(currentMetrics.uniqueUsers)
.prevUniqueUsers(prevMetrics.uniqueUsers)
.successRate(currentMetrics.successRate)
.prevSuccessRate(prevMetrics.successRate)
.avgLatencyMs(currentMetrics.avgLatencyMs)
.prevAvgLatencyMs(prevMetrics.avgLatencyMs)
.errorCount(currentMetrics.errorCount)
.topEventType(currentMetrics.topEventType)
.topUser(currentMetrics.topUser)
.eventsByType(currentMetrics.eventsByType)
.eventsByUser(currentMetrics.eventsByUser)
.topTools(currentMetrics.topTools)
.hourlyDistribution(hourlyDistribution)
.build());
return Response.ok(
AuditStatsData.builder()
.totalEvents(currentMetrics.totalEvents)
.prevTotalEvents(prevMetrics.totalEvents)
.uniqueUsers(currentMetrics.uniqueUsers)
.prevUniqueUsers(prevMetrics.uniqueUsers)
.successRate(currentMetrics.successRate)
.prevSuccessRate(prevMetrics.successRate)
.avgLatencyMs(currentMetrics.avgLatencyMs)
.prevAvgLatencyMs(prevMetrics.avgLatencyMs)
.errorCount(currentMetrics.errorCount)
.topEventType(currentMetrics.topEventType)
.topUser(currentMetrics.topUser)
.eventsByType(currentMetrics.eventsByType)
.eventsByUser(currentMetrics.eventsByUser)
.topTools(currentMetrics.topTools)
.hourlyDistribution(hourlyDistribution)
.build())
.build();
}
/** Compute metrics from a list of audit events. */
@@ -520,31 +535,30 @@ public class AuditRestController {
* "date,username,tool,documentName,author,fileHash")
* @param eventTypes Filter by event type(s) - can be single value or array
* @param usernames Filter by username(s) - can be single value or array
* @param startDate Filter start date
* @param endDate Filter end date
* @param startDateStr Filter start date (ISO yyyy-MM-dd)
* @param endDateStr Filter end date (ISO yyyy-MM-dd)
* @return File download response
*/
@GetMapping("/audit-export")
public ResponseEntity<byte[]> exportAuditData(
@RequestParam(value = "format", defaultValue = "csv") String format,
@RequestParam(value = "fields", required = false) String fields,
@RequestParam(value = "eventType", required = false) String[] eventTypes,
@RequestParam(value = "username", required = false) String[] usernames,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate endDate) {
@GET
@jakarta.ws.rs.Path("/audit-export")
public Response exportAuditData(
@QueryParam("format") @jakarta.ws.rs.DefaultValue("csv") String format,
@QueryParam("fields") String fields,
@QueryParam("eventType") List<String> eventTypes,
@QueryParam("username") List<String> usernames,
@QueryParam("startDate") String startDateStr,
@QueryParam("endDate") String endDateStr) {
LocalDate startDate = parseIsoDate(startDateStr);
LocalDate endDate = parseIsoDate(endDateStr);
// Get data with same filtering as getAuditEvents
List<PersistentAuditEvent> events;
// Convert arrays to lists
List<String> eventTypeList =
(eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
List<String> usernameList =
(usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
(eventTypes != null && !eventTypes.isEmpty()) ? eventTypes : null;
List<String> usernameList = (usernames != null && !usernames.isEmpty()) ? usernames : null;
Instant startInstant = null;
Instant endInstant = null;
@@ -592,6 +606,19 @@ public class AuditRestController {
// Helper methods
/** Parse an ISO yyyy-MM-dd date string, returning null when blank/unparseable. */
private LocalDate parseIsoDate(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
try {
return LocalDate.parse(value.trim());
} catch (Exception e) {
log.trace("Failed to parse ISO date value: {}", value);
return null;
}
}
private AuditEventDto convertToDto(PersistentAuditEvent event) {
// Parse the JSON data field if present
Map<String, Object> details = new HashMap<>();
@@ -628,7 +655,7 @@ public class AuditRestController {
.build();
}
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events, String fields) {
private Response exportAsCsv(List<PersistentAuditEvent> events, String fields) {
// Parse selected fields (comma-separated:
// date,username,tool,documentName,author,fileHash,ipAddress,etc)
Set<String> selectedFields = new HashSet<>();
@@ -680,15 +707,17 @@ public class AuditRestController {
}
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
headers.setContentDispositionFormData(
"attachment", "audit_export_" + System.currentTimeMillis() + ".csv");
return ResponseEntity.ok().headers(headers).body(csvBytes);
return Response.ok(csvBytes)
.header(HttpHeaders.CONTENT_TYPE, "text/csv;charset=UTF-8")
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"audit_export_"
+ System.currentTimeMillis()
+ ".csv\"")
.build();
}
private ResponseEntity<byte[]> exportAsDefaultCsv(List<PersistentAuditEvent> events) {
private Response exportAsDefaultCsv(List<PersistentAuditEvent> events) {
StringBuilder csv = new StringBuilder();
csv.append("ID,Principal,Type,Timestamp,Data\n");
@@ -703,11 +732,12 @@ public class AuditRestController {
}
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
headers.setContentDispositionFormData("attachment", "audit_export.csv");
return ResponseEntity.ok().headers(headers).body(csvBytes);
return Response.ok(csvBytes)
.header(HttpHeaders.CONTENT_TYPE, "text/csv;charset=UTF-8")
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"audit_export.csv\"")
.build();
}
private Map<String, String> extractEventData(
@@ -798,18 +828,19 @@ public class AuditRestController {
};
}
private ResponseEntity<byte[]> exportAsJson(List<PersistentAuditEvent> events) {
private Response exportAsJson(List<PersistentAuditEvent> events) {
try {
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setContentDispositionFormData("attachment", "audit_export.json");
return ResponseEntity.ok().headers(headers).body(jsonBytes);
return Response.ok(jsonBytes)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"audit_export.json\"")
.build();
} catch (JacksonException e) {
log.error("Error serializing audit events to JSON", e);
return ResponseEntity.internalServerError().build();
return Response.serverError().build();
}
}
@@ -900,18 +931,20 @@ public class AuditRestController {
*
* @return Success response
*/
@PostMapping("/audit-clear-all")
public ResponseEntity<?> clearAllAuditData() {
@POST
@jakarta.ws.rs.Path("/audit-clear-all")
public Response clearAllAuditData() {
try {
// Delete all audit events
auditRepository.deleteAll();
log.warn("All audit data has been cleared by admin user");
return ResponseEntity.ok()
.body(Map.of("message", "All audit data has been cleared successfully"));
return Response.ok(Map.of("message", "All audit data has been cleared successfully"))
.build();
} catch (Exception e) {
log.error("Error clearing audit data", e);
return ResponseEntity.internalServerError()
.body("Failed to clear audit data: " + e.getMessage());
return Response.serverError()
.entity("Failed to clear audit data: " + e.getMessage())
.build();
}
}
}
@@ -6,14 +6,14 @@ import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.Resource;
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 io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
@@ -39,8 +39,8 @@ import stirling.software.common.util.WebResponseUtils;
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/ai/tools")
@ApplicationScoped
@jakarta.ws.rs.Path("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class CreatePdfAgentController {
@@ -70,18 +70,17 @@ public class CreatePdfAgentController {
return false;
}
@PostMapping(
value = "/create-pdf-from-html-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@POST
@jakarta.ws.rs.Path("/create-pdf-from-html-agent")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "Convert AI-generated HTML to a PDF",
description =
"Accepts an HTML document as a plain-text parameter and returns a PDF."
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
+ " plan step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdfFromHtml(
@RequestParam("htmlContent") String htmlContent,
@RequestParam("filename") String filename)
public Response createPdfFromHtml(
@RestForm("htmlContent") String htmlContent, @RestForm("filename") String filename)
throws Exception {
log.info(
@@ -3,14 +3,15 @@ package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.math.BigDecimal;
import org.springframework.http.HttpStatus;
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.springframework.web.multipart.MultipartFile;
import jakarta.enterprise.context.ApplicationScoped;
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 org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -19,6 +20,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.multipart.FileUploadMultipartFile;
import stirling.software.proprietary.model.api.ai.Verdict;
import stirling.software.proprietary.service.AiToolInputValidator;
import stirling.software.proprietary.service.MathAuditorOrchestrator;
@@ -29,26 +32,28 @@ import stirling.software.proprietary.service.MathAuditorOrchestrator;
* <p>Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the
* multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict} as JSON.
*
* <p>This endpoint is a pure specialist — it produces the structured finding and nothing more.
* <p>This endpoint is a pure specialist - it produces the structured finding and nothing more.
* Presentation (rendering as a chat answer, projecting to PDF comments, etc.) is the responsibility
* of the caller (e.g. the orchestrator's {@code delegate_pdf_question} or {@code
* delegate_pdf_review} meta-agents).
*
* <p>Lives under {@code /api/v1/ai/tools/} so it is dispatchable by the AI orchestrator via the
* standard {@code InternalApiClient} allowlist — no special-case plumbing needed.
* standard {@code InternalApiClient} allowlist - no special-case plumbing needed.
*
* <p>The raw PDF never leaves Java. Python receives only structured text and CSV data.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/ai/tools")
@ApplicationScoped
@Path("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class MathAuditorAgentController {
private final MathAuditorOrchestrator orchestrator;
@PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@POST
@Path("/math-auditor-agent")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "Validate mathematical calculations in a PDF",
description =
@@ -67,34 +72,37 @@ public class MathAuditorAgentController {
Input: PDF Output: JSON Type: SISO
""")
public ResponseEntity<Verdict> mathAuditorAgent(
public Response mathAuditorAgent(
@Parameter(description = "The PDF document to audit", required = true)
@RequestParam("fileInput")
MultipartFile fileInput,
@RestForm("fileInput")
FileUpload fileInput,
@Parameter(
description =
"Arithmetic tolerance — differences smaller than this are"
"Arithmetic tolerance - differences smaller than this are"
+ " ignored (default: 0.01)")
@RequestParam(value = "tolerance", defaultValue = "0.01")
@RestForm("tolerance")
BigDecimal tolerance) {
AiToolInputValidator.validatePdfUpload(fileInput);
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
return ResponseEntity.badRequest().build();
BigDecimal effectiveTolerance = tolerance != null ? tolerance : new BigDecimal("0.01");
MultipartFile fileInputMpf = FileUploadMultipartFile.of(fileInput);
AiToolInputValidator.validatePdfUpload(fileInputMpf);
if (effectiveTolerance.compareTo(BigDecimal.ZERO) < 0) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
String safeName =
fileInput.getOriginalFilename() != null
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
fileInputMpf.getOriginalFilename() != null
? fileInputMpf.getOriginalFilename().replaceAll("[\\r\\n]", "_")
: "<unnamed>";
log.info("[math-auditor-agent] request file={} tolerance={}", safeName, tolerance);
log.info("[math-auditor-agent] request file={} tolerance={}", safeName, effectiveTolerance);
try {
Verdict verdict = orchestrator.audit(fileInput, tolerance);
return ResponseEntity.ok(verdict);
Verdict verdict = orchestrator.audit(fileInputMpf, effectiveTolerance);
return Response.ok(verdict).build();
} catch (IOException e) {
log.error("[math-auditor-agent] IO error during audit", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -2,24 +2,27 @@ package stirling.software.proprietary.controller.api;
import java.io.IOException;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
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.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.multipart.FileUploadMultipartFile;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
@@ -39,19 +42,18 @@ import tools.jackson.databind.node.ObjectNode;
* <p>The raw PDF never leaves Java. Python only receives positioned text chunks.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/ai/tools")
@RequiredArgsConstructor
@ApplicationScoped
@Path("/api/v1/ai/tools")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class PdfCommentAgentController {
private final PdfCommentAgentOrchestrator orchestrator;
private final ObjectMapper objectMapper;
@Inject PdfCommentAgentOrchestrator orchestrator;
@Inject ObjectMapper objectMapper;
@PostMapping(
value = "/pdf-comment-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_PDF_VALUE)
@POST
@Path("/pdf-comment-agent")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces("application/pdf")
@Operation(
summary = "Annotate a PDF with AI-generated sticky-note comments",
description =
@@ -66,18 +68,20 @@ public class PdfCommentAgentController {
Input: PDF + prompt Output: PDF Type: SISO
""")
public ResponseEntity<Resource> pdfCommentAgent(
public Response pdfCommentAgent(
@Parameter(description = "The PDF document to annotate", required = true)
@RequestParam("fileInput")
MultipartFile fileInput,
@RestForm("fileInput")
FileUpload fileInputUpload,
@Parameter(
description =
"Natural-language instructions for the AI — what to comment on",
"Natural-language instructions for the AI - what to comment on",
required = true)
@RequestParam("prompt")
@RestForm("prompt")
String prompt)
throws IOException {
MultipartFile fileInput = FileUploadMultipartFile.of(fileInputUpload);
String safeName =
fileInput.getOriginalFilename() != null
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
@@ -87,15 +91,19 @@ public class PdfCommentAgentController {
safeName,
prompt == null ? 0 : prompt.length());
// ResponseStatusException (validation errors) propagates to Spring's default handler;
// ResponseStatusException (validation errors) propagates to the default handler;
// IOException is re-thrown to produce a 500. Other RuntimeExceptions likewise propagate.
AnnotatedPdf annotated = orchestrator.applyComments(fileInput, prompt);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", annotated.fileName());
headers.setContentLength(annotated.bytes().length);
headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated));
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(annotated.bytes()));
return Response.ok(annotated.bytes())
.type("application/pdf")
.header(HttpHeaders.CONTENT_LENGTH, annotated.bytes().length)
.header(
"Content-Disposition",
"form-data; name=\"attachment\"; filename=\""
+ annotated.fileName()
+ "\"")
.header(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated))
.build();
}
/**
@@ -6,14 +6,16 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import io.quarkus.security.identity.SecurityIdentity;
import io.swagger.v3.oas.annotations.Operation;
@@ -45,7 +47,6 @@ import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
@@ -57,6 +58,8 @@ import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@ApplicationScoped
@Path("/api/v1/proprietary/ui-data")
@ProprietaryUiDataApi
public class ProprietaryUIDataController {
@@ -74,6 +77,9 @@ public class ProprietaryUIDataController {
private final MfaService mfaService;
private final LoginAttemptService loginAttemptService;
@Inject SecurityIdentity securityIdentity;
@Inject
public ProprietaryUIDataController(
ApplicationProperties applicationProperties,
AuditConfigurationProperties auditConfig,
@@ -83,7 +89,7 @@ public class ProprietaryUIDataController {
SessionRepository sessionRepository,
DatabaseServiceInterface databaseService,
ObjectMapper objectMapper,
@Qualifier("runningEE") boolean runningEE,
@Named("runningEE") boolean runningEE,
UserLicenseSettingsService licenseSettingsService,
PersistentAuditEventRepository auditRepository,
MfaService mfaService,
@@ -119,11 +125,12 @@ public class ProprietaryUIDataController {
return "http://localhost:8080";
}
@GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')")
@GET
@Path("/audit-dashboard")
@RolesAllowed("ADMIN")
@EnterpriseEndpoint
@Operation(summary = "Get audit dashboard data")
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
public Response getAuditDashboardData() {
AuditDashboardData data = new AuditDashboardData();
data.setAuditEnabled(auditConfig.isEnabled());
data.setAuditLevel(auditConfig.getAuditLevel());
@@ -139,12 +146,13 @@ public class ProprietaryUIDataController {
data.setPdfMetadataEnabled(
auditConfig.isCaptureFileHash() || auditConfig.isCapturePdfAuthor());
return ResponseEntity.ok(data);
return Response.ok(data).build();
}
@GetMapping("/login")
@GET
@Path("/login")
@Operation(summary = "Get login page data")
public ResponseEntity<LoginData> getLoginData() {
public Response getLoginData() {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
@@ -247,13 +255,14 @@ public class ProprietaryUIDataController {
data.setLanguages(applicationProperties.getUi().getLanguages());
data.setDefaultLocale(applicationProperties.getSystem().getDefaultLocale());
return ResponseEntity.ok(data);
return Response.ok(data).build();
}
@GetMapping("/admin-settings")
@PreAuthorize("hasRole('ADMIN')")
@GET
@Path("/admin-settings")
@RolesAllowed("ADMIN")
@Operation(summary = "Get admin settings data")
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
public Response getAdminSettingsData() {
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
@@ -375,7 +384,7 @@ public class ProprietaryUIDataController {
AdminSettingsData data = new AdminSettingsData();
data.setUsers(userSummaries);
data.setCurrentUsername(authentication.getName());
data.setCurrentUsername(securityIdentity.getPrincipal().getName());
data.setRoleDetails(roleDetails);
data.setUserSessions(userSessions);
data.setUserLastRequest(userLastRequest);
@@ -393,39 +402,37 @@ public class ProprietaryUIDataController {
data.setUserSettings(userSettings);
data.setLockedUsers(loginAttemptService.getAllBlockedUsers());
return ResponseEntity.ok(data);
return Response.ok(data).build();
}
@GetMapping("/account")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@GET
@Path("/account")
// TODO: Migration required - Spring "!hasAuthority('ROLE_DEMO_USER')" (negated authority) has
// no @RolesAllowed equivalent. Enforce the DEMO_USER exclusion via a Quarkus
// SecurityIdentity check below / an augmentor, or quarkus.http.auth.* policy.
@Operation(summary = "Get account page data")
public ResponseEntity<AccountData> getAccountData(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(401).build();
public Response getAccountData() {
if (securityIdentity == null || securityIdentity.isAnonymous()) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
Object principal = authentication.getPrincipal();
String username = null;
// TODO: Migration required - Spring distinguished UserDetails / OAuth2User /
// CustomSaml2AuthenticatedPrincipal off authentication.getPrincipal() to set the
// oAuth2Login / saml2Login flags. Under Quarkus the auth mechanism is exposed via
// SecurityIdentity attributes (e.g. quarkus-oidc IdToken / SAML augmentor). Until OAuth2/
// SAML are wired to quarkus-oidc, only the username is resolved and the login-type flags
// default to false.
String username = securityIdentity.getPrincipal().getName();
boolean isOAuth2Login = false;
boolean isSaml2Login = false;
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
isOAuth2Login = true;
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
username = saml2User.name();
isSaml2Login = true;
}
if (username == null) {
return ResponseEntity.status(401).build();
return Response.status(Response.Status.UNAUTHORIZED).build();
}
Optional<User> user = userRepository.findByUsernameIgnoreCaseWithSettings(username);
if (user.isEmpty()) {
return ResponseEntity.status(404).build();
return Response.status(Response.Status.NOT_FOUND).build();
}
String settingsJson;
@@ -433,7 +440,7 @@ public class ProprietaryUIDataController {
settingsJson = objectMapper.writeValueAsString(user.get().getSettings());
} catch (JacksonException e) {
log.error("Error converting settings map", e);
return ResponseEntity.status(500).build();
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
AccountData data = new AccountData();
@@ -446,13 +453,14 @@ public class ProprietaryUIDataController {
data.setMfaEnabled(mfaService.isMfaEnabled(user.get()));
data.setMfaRequired(mfaService.isMfaRequired(user.get()));
return ResponseEntity.ok(data);
return Response.ok(data).build();
}
@GetMapping("/teams")
@PreAuthorize("hasRole('ADMIN')")
@GET
@Path("/teams")
@RolesAllowed("ADMIN")
@Operation(summary = "Get teams list data")
public ResponseEntity<TeamsData> getTeamsData() {
public Response getTeamsData() {
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
List<TeamWithUserCountDTO> teamsWithCounts =
allTeamsWithCounts.stream()
@@ -472,20 +480,21 @@ public class ProprietaryUIDataController {
data.setTeamsWithCounts(teamsWithCounts);
data.setTeamLastRequest(teamLastRequest);
return ResponseEntity.ok(data);
return Response.ok(data).build();
}
@GetMapping("/teams/{id}")
@PreAuthorize("hasRole('ADMIN')")
@GET
@Path("/teams/{id}")
@RolesAllowed("ADMIN")
@Operation(summary = "Get team details data")
public ResponseEntity<TeamDetailsData> getTeamDetailsData(@PathVariable("id") Long id) {
public Response getTeamDetailsData(@PathParam("id") Long id) {
Team team =
teamRepository
.findById(id)
.orElseThrow(() -> new RuntimeException("Team not found"));
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
return ResponseEntity.status(403).build();
return Response.status(Response.Status.FORBIDDEN).build();
}
List<User> teamUsers = userRepository.findAllByTeamId(id);
@@ -516,13 +525,14 @@ public class ProprietaryUIDataController {
data.setAvailableUsers(availableUsers);
data.setUserLastRequest(userLastRequest);
return ResponseEntity.ok(data);
return Response.ok(data).build();
}
@GetMapping("/database")
@PreAuthorize("hasRole('ADMIN')")
@GET
@Path("/database")
@RolesAllowed("ADMIN")
@Operation(summary = "Get database management data")
public ResponseEntity<DatabaseData> getDatabaseData() {
public Response getDatabaseData() {
List<FileInfo> backupList = databaseService.getBackupList();
String dbVersion = databaseService.getH2Version();
boolean isVersionUnknown = "Unknown".equalsIgnoreCase(dbVersion);
@@ -532,7 +542,7 @@ public class ProprietaryUIDataController {
data.setDatabaseVersion(dbVersion);
data.setVersionUnknown(isVersionUnknown);
return ResponseEntity.ok(data);
return Response.ok(data).build();
}
/**
@@ -8,16 +8,15 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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.RestController;
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.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -32,12 +31,19 @@ import stirling.software.proprietary.service.SignatureService;
/**
* Controller for managing user signatures in proprietary/authenticated mode only. Requires user
* authentication and enforces per-user storage limits. All endpoints require authentication
* via @PreAuthorize("isAuthenticated()").
* authentication and enforces per-user storage limits.
*
* <p>TODO: Migration required - the original endpoints were guarded by Spring Security SpEL
* expressions ({@code @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")} and
* {@code @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")}). These are not simple role checks, so
* they cannot be expressed with {@code @RolesAllowed}. Authentication should be enforced via Quarkus
* (e.g. inject {@code io.quarkus.security.identity.SecurityIdentity} or add an HTTP auth policy in
* application.properties), and the DEMO_USER exclusion needs to be re-implemented as a runtime check
* against the current identity's roles.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/signatures")
@ApplicationScoped
@jakarta.ws.rs.Path("/api/v1/proprietary/signatures")
@RequiredArgsConstructor
@Tag(
name = "Saved Signatures",
@@ -52,10 +58,13 @@ public class SignatureController {
* Save a new signature for the authenticated user. Enforces storage limits and authentication
* requirements.
*/
@PostMapping
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<SavedSignatureResponse> saveSignature(
@RequestBody SavedSignatureRequest request) {
// TODO: Migration required - replace @PreAuthorize("isAuthenticated() &&
// !hasAuthority('ROLE_DEMO_USER')") with a Quarkus authentication policy + DEMO_USER runtime
// guard.
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response saveSignature(SavedSignatureRequest request) {
try {
String username = userService.getCurrentUsername();
@@ -63,24 +72,24 @@ public class SignatureController {
log.warn(
"User {} attempted to create shared signature without admin role",
username);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return Response.status(Response.Status.FORBIDDEN).build();
}
// Validate request
if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) {
log.warn("User {} attempted to save signature without dataUrl", username);
return ResponseEntity.badRequest().build();
return Response.status(Response.Status.BAD_REQUEST).build();
}
SavedSignatureResponse response = signatureService.saveSignature(username, request);
log.info("User {} saved signature {}", username, request.getId());
return ResponseEntity.ok(response);
return Response.ok(response).build();
} catch (IllegalArgumentException e) {
log.warn("Invalid signature save request: {}", e.getMessage());
return ResponseEntity.badRequest().build();
return Response.status(Response.Status.BAD_REQUEST).build();
} catch (IOException e) {
log.error("Failed to save signature", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
}
@@ -88,16 +97,19 @@ public class SignatureController {
* List all signatures accessible to the authenticated user. Includes both personal and shared
* signatures.
*/
@GetMapping
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<List<SavedSignatureResponse>> listSignatures() {
// TODO: Migration required - replace @PreAuthorize("isAuthenticated() &&
// !hasAuthority('ROLE_DEMO_USER')") with a Quarkus authentication policy + DEMO_USER runtime
// guard.
@GET
@Produces(MediaType.APPLICATION_JSON)
public Response listSignatures() {
try {
String username = userService.getCurrentUsername();
List<SavedSignatureResponse> signatures = signatureService.getSavedSignatures(username);
return ResponseEntity.ok(signatures);
return Response.ok(signatures).build();
} catch (IOException e) {
log.error("Failed to list signatures for user", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
}
@@ -105,10 +117,13 @@ public class SignatureController {
* Update a signature label. Users can update labels for their own personal signatures and for
* shared signatures.
*/
@PostMapping("/{signatureId}/label")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> updateSignatureLabel(
@PathVariable String signatureId, @RequestBody Map<String, String> body) {
// TODO: Migration required - replace @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") with a
// DEMO_USER runtime guard against the current identity's roles.
@POST
@jakarta.ws.rs.Path("/{signatureId}/label")
@Consumes(MediaType.APPLICATION_JSON)
public Response updateSignatureLabel(
@PathParam("signatureId") String signatureId, Map<String, String> body) {
try {
String username = userService.getCurrentUsername();
String newLabel = body.get("label");
@@ -116,7 +131,7 @@ public class SignatureController {
if (newLabel == null || newLabel.trim().isEmpty()) {
log.warn("Invalid label update request");
return ResponseEntity.badRequest().build();
return Response.status(Response.Status.BAD_REQUEST).build();
}
if (signatureService.isSharedSignature(signatureId) && !isAdmin) {
@@ -124,15 +139,15 @@ public class SignatureController {
"User {} attempted to update shared signature {} without admin role",
username,
signatureId);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return Response.status(Response.Status.FORBIDDEN).build();
}
signatureService.updateSignatureLabel(username, signatureId, newLabel);
log.info("User {} updated label for signature {}", username, signatureId);
return ResponseEntity.noContent().build();
return Response.noContent().build();
} catch (IOException e) {
log.warn("Failed to update signature label: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@@ -140,9 +155,11 @@ public class SignatureController {
* Delete a signature owned by the authenticated user. Users can delete their own personal
* signatures. Admins can also delete shared signatures.
*/
@DeleteMapping("/{signatureId}")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
// TODO: Migration required - replace @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") with a
// DEMO_USER runtime guard against the current identity's roles.
@DELETE
@jakarta.ws.rs.Path("/{signatureId}")
public Response deleteSignature(@PathParam("signatureId") String signatureId) {
try {
String username = userService.getCurrentUsername();
boolean isAdmin = userService.isCurrentUserAdmin();
@@ -152,21 +169,21 @@ public class SignatureController {
|| signatureId.contains("/")
|| signatureId.contains("\\")) {
log.warn("Invalid signature ID: {}", signatureId);
return ResponseEntity.badRequest().build();
return Response.status(Response.Status.BAD_REQUEST).build();
}
// Try to delete from personal folder first
try {
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted personal signature {}", username, signatureId);
return ResponseEntity.noContent().build();
return Response.noContent().build();
} catch (IOException e) {
// If not found in personal folder, check if it's in shared folder
if (isAdmin) {
// Admin can delete from shared folder
if (deleteFromSharedFolder(signatureId)) {
log.info("Admin {} deleted shared signature {}", username, signatureId);
return ResponseEntity.noContent().build();
return Response.noContent().build();
}
}
// If not admin or not found in shared folder either, return 404
@@ -174,7 +191,7 @@ public class SignatureController {
}
} catch (IOException e) {
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@@ -5,10 +5,13 @@ import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -24,8 +27,10 @@ import tools.jackson.databind.ObjectMapper;
/** REST API controller for usage analytics data used by React frontend. */
@Slf4j
@ApplicationScoped
@ProprietaryUiDataApi
@PreAuthorize("hasRole('ADMIN')")
@Path("/api/v1/proprietary/ui-data")
@RolesAllowed("ADMIN")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class UsageRestController {
@@ -43,11 +48,12 @@ public class UsageRestController {
* @param days Lookback window in days (default 30, clamped to 1-365)
* @return Endpoint statistics response
*/
@GetMapping("/usage-endpoint-statistics")
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
@RequestParam(value = "limit", required = false) Integer limit,
@RequestParam(value = "dataType", defaultValue = "all") String dataType,
@RequestParam(value = "days", defaultValue = "30") Integer days) {
@GET
@Path("/usage-endpoint-statistics")
public Response getEndpointStatistics(
@QueryParam("limit") Integer limit,
@QueryParam("dataType") @DefaultValue("all") String dataType,
@QueryParam("days") @DefaultValue("30") Integer days) {
int lookbackDays = Math.max(1, Math.min(days, 365));
@@ -99,7 +105,7 @@ public class UsageRestController {
.totalVisits((int) totalVisits)
.build();
return ResponseEntity.ok(response);
return Response.ok(response).build();
}
/**
@@ -5,16 +5,15 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
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 jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import io.quarkus.arc.lookup.LookupIfProperty;
import io.quarkus.security.identity.SecurityIdentity;
import lombok.extern.slf4j.Slf4j;
@@ -30,9 +29,14 @@ import tools.jackson.databind.node.ObjectNode;
/** Streamable-HTTP MCP server endpoint serving JSON-RPC 2.0 frames on {@code POST /mcp}. */
@Slf4j
@RestController
@RequestMapping
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@jakarta.ws.rs.Path("/mcp")
// @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") -> LookupIfProperty.
// LookupIfProperty gates programmatic lookup; for a JAX-RS resource Quarkus always registers the
// endpoint. TODO: Migration required - to truly disable the /mcp route when mcp.enabled=false,
// add a runtime guard (e.g. reject in handle() when disabled) or use a build-time conditional;
// LookupIfProperty alone does not unregister the REST path.
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class McpServerController {
private static final String PREFERRED_PROTOCOL_VERSION = "2025-06-18";
@@ -44,6 +48,9 @@ public class McpServerController {
private final ApplicationProperties applicationProperties;
private final Map<String, McpTool> toolsByName;
@Inject SecurityIdentity securityIdentity;
@Inject
public McpServerController(
ObjectMapper mapper, ApplicationProperties applicationProperties, List<McpTool> tools) {
this.mapper = mapper;
@@ -58,24 +65,24 @@ public class McpServerController {
toolsByName.keySet());
}
@PostMapping(
path = "/mcp",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> handle(@RequestBody JsonNode body) {
@POST
@Consumes(MediaType.APPLICATION_JSON)
@jakarta.ws.rs.Produces(MediaType.APPLICATION_JSON)
public Response handle(JsonNode body) {
JsonRpcRequest request = decode(body);
if (request == null) {
// Valid JSON but not a JSON-RPC request -> Invalid Request, not Parse error.
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
JsonRpcResponse.failure(
null,
JsonRpcError.invalidRequest(
"Body is not a valid JSON-RPC 2.0 request")));
"Body is not a valid JSON-RPC 2.0 request")))
.build();
}
if (request.isNotification()) {
log.debug("Notification received: {}", sanitizeForLog(request.method()));
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
return Response.status(Response.Status.NO_CONTENT).build();
}
JsonRpcResponse response;
try {
@@ -92,17 +99,23 @@ public class McpServerController {
JsonRpcError.internalError(
"Internal error handling " + request.method()));
}
return ResponseEntity.ok(response);
return Response.ok(response).build();
}
/** Wrap malformed-JSON failures (caught before {@link #handle}) as a JSON-RPC Parse error. */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<JsonRpcResponse> handleUnreadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.body(
// Spring's @ExceptionHandler(HttpMessageNotReadableException.class) wrapped malformed-JSON
// failures as a JSON-RPC Parse error. In JAX-RS this maps to a
// jakarta.ws.rs.ext.ExceptionMapper provider. TODO: Migration required - move this handling to
// a @Provider ExceptionMapper<...> (e.g. mapping the JSON deserialization exception thrown by
// the Jackson MessageBodyReader) returning HTTP 400 with
// JsonRpcResponse.failure(null, JsonRpcError.parseError("Request body is not valid JSON")).
// Kept here for reference; it is no longer wired as an exception handler.
private Response handleUnreadable() {
return Response.status(Response.Status.BAD_REQUEST)
.type(MediaType.APPLICATION_JSON)
.entity(
JsonRpcResponse.failure(
null, JsonRpcError.parseError("Request body is not valid JSON")));
null, JsonRpcError.parseError("Request body is not valid JSON")))
.build();
}
private static String sanitizeForLog(String value) {
@@ -197,21 +210,32 @@ public class McpServerController {
private McpCallContext resolveContext() {
boolean scopesEnabled = applicationProperties.getMcp().isScopesEnabled();
org.springframework.security.core.Authentication auth =
org.springframework.security.core.context.SecurityContextHolder.getContext()
.getAuthentication();
// Fail closed: no/unauthenticated principal yields an empty context so scoped ops are
// refused.
if (auth == null || !auth.isAuthenticated() || auth.getName() == null) {
// Spring SecurityContextHolder.getContext().getAuthentication() -> Quarkus SecurityIdentity.
// Fail closed: an anonymous/unauthenticated identity yields an empty context so scoped ops
// are refused.
if (securityIdentity == null
|| securityIdentity.isAnonymous()
|| securityIdentity.getPrincipal() == null
|| securityIdentity.getPrincipal().getName() == null) {
return new McpCallContext(null, Set.of(), scopesEnabled);
}
java.util.Set<String> scopes = new java.util.HashSet<>();
for (org.springframework.security.core.GrantedAuthority ga : auth.getAuthorities()) {
String authority = ga.getAuthority();
if (authority != null && authority.startsWith("SCOPE_")) {
scopes.add(authority.substring("SCOPE_".length()));
// TODO: Migration required - the Spring code derived scopes from GrantedAuthority values
// prefixed with "SCOPE_". Quarkus SecurityIdentity.getRoles() typically already carries the
// bare role/scope names (quarkus-oidc maps OIDC scopes to roles without the SCOPE_ prefix).
// Confirm the configured quarkus.oidc role/scope mapping; if scopes arrive as a "scope"
// claim, read them via securityIdentity.getAttribute("scope")/getClaims() instead. For now
// we accept both the bare role and any "SCOPE_"-prefixed authority for parity.
for (String role : securityIdentity.getRoles()) {
if (role == null) {
continue;
}
if (role.startsWith("SCOPE_")) {
scopes.add(role.substring("SCOPE_".length()));
} else {
scopes.add(role);
}
}
return new McpCallContext(auth.getName(), scopes, scopesEnabled);
return new McpCallContext(securityIdentity.getPrincipal().getName(), scopes, scopesEnabled);
}
}
@@ -2,25 +2,17 @@ package stirling.software.proprietary.mcp.catalog;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import io.quarkus.runtime.StartupEvent;
import io.swagger.v3.oas.annotations.Operation;
@@ -33,19 +25,23 @@ import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on {@link
* ContextRefreshedEvent} and filtered on read by {@link
* Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on
* application startup ({@code @Observes StartupEvent}) and filtered on read by {@link
* EndpointConfiguration#isEndpointEnabledForUri}. AI capabilities are fed in via {@link
* #replaceAiCapabilities}.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
// TODO: Migration required - the original @ConditionalOnProperty(name = "mcp.enabled",
// havingValue = "true") gated this bean on a runtime property. Quarkus build-time conditions
// (@io.quarkus.arc.lookup.LookupIfProperty / @io.quarkus.arc.profile.IfBuildProfile) cannot honour
// a purely runtime toggle. The bean is now always present; callers must guard on
// applicationProperties.getMcp() / a runtime "mcp.enabled" check, or wire @LookupIfProperty on the
// injection points once "mcp.enabled" is promoted to a build-time property.
public class McpToolCatalog {
private static final String WRITE_SCOPE = "mcp.tools.write";
private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
private final ApplicationProperties applicationProperties;
private final SimpleSchemaGenerator schemaGenerator;
@@ -60,12 +56,11 @@ public class McpToolCatalog {
// never a partially-merged one.
private volatile Map<String, OperationMeta> aiOps = new ConcurrentHashMap<>();
@Inject
public McpToolCatalog(
ApplicationContext applicationContext,
EndpointConfiguration endpointConfiguration,
ApplicationProperties applicationProperties,
ObjectMapper objectMapper) {
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.applicationProperties = applicationProperties;
this.schemaGenerator = new SimpleSchemaGenerator(objectMapper);
@@ -86,52 +81,38 @@ public class McpToolCatalog {
return true;
}
@EventListener(ContextRefreshedEvent.class)
public void discover() {
void discover(@Observes StartupEvent event) {
pdfOps.clear();
for (RequestMappingHandlerMapping mapping :
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
for (Map.Entry<RequestMappingInfo, HandlerMethod> e :
mapping.getHandlerMethods().entrySet()) {
indexOne(e.getKey(), e.getValue());
}
}
// TODO: Migration required - endpoint discovery relied on Spring MVC's
// RequestMappingHandlerMapping (ApplicationContext.getBeansOfType(...) ->
// mapping.getHandlerMethods()) to enumerate every @RequestMapping/@PostMapping handler,
// its URL patterns (RequestMappingInfo#getDirectPaths), its HTTP methods
// (RequestMethod POST/PUT), and the HandlerMethod/MethodParameter reflection used to build
// request schemas. Quarkus/RESTEasy Reactive has no equivalent runtime registry of JAX-RS
// resources. To restore catalog population, replace this with one of:
// (a) a build-time scan via a Quarkus extension / @io.quarkus.runtime.annotations.Recorder
// over Jandex-indexed @Path + @POST/@PUT methods, or
// (b) a custom registry populated as endpoints register themselves, or
// (c) classpath reflection (Jandex CombinedIndexBuildItem) over the @XxxApi-annotated
// resource classes.
// The per-handler helpers below (buildMeta/paramSchemaFor/firstComplexParamType/indexOne/
// extractPatterns/isInvocableMethod) all depended on Spring MVC types and have been removed;
// the schema-generation logic (SimpleSchemaGenerator) and OperationMeta model are reusable
// once a Quarkus-native handler enumeration is supplied.
log.info("MCP tool catalog discovered {} PDF operation(s)", pdfOps.size());
}
private void indexOne(RequestMappingInfo info, HandlerMethod handler) {
Set<String> patterns = extractPatterns(info);
if (patterns.isEmpty()) {
return;
}
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
if (!isInvocableMethod(methods)) {
return;
}
for (String pattern : patterns) {
OperationCategory category = OperationCategory.fromUrl(pattern);
if (category == null) {
continue;
}
String opId = extractOpId(pattern, category);
if (opId == null) {
continue;
}
OperationMeta meta = buildMeta(opId, category, pattern, handler);
// First handler wins on duplicate URLs.
pdfOps.putIfAbsent(opId, meta);
}
}
private OperationMeta buildMeta(
String opId, OperationCategory category, String url, HandlerMethod handler) {
Method method = handler.getMethod();
String opId, OperationCategory category, String url, Method method) {
Operation opAnno = method.getAnnotation(Operation.class);
String summary =
opAnno != null && !opAnno.summary().isBlank()
? opAnno.summary()
: prettifyOpId(opId);
ObjectNode schema = paramSchemaFor(handler);
// TODO: Migration required - request body type was previously resolved from Spring's
// HandlerMethod#getMethodParameters(); resolve the first complex parameter type via plain
// reflection on the JAX-RS resource method instead, then call schemaGenerator.toSchema(...).
ObjectNode schema = paramSchemaFor(method);
// Every mutating endpoint requires the write scope.
return new OperationMeta(
opId,
@@ -141,11 +122,11 @@ public class McpToolCatalog {
WRITE_SCOPE,
OperationMeta.Target.JAVA_ENDPOINT,
url,
handler);
method);
}
private ObjectNode paramSchemaFor(HandlerMethod handler) {
Optional<Class<?>> bodyType = firstComplexParamType(handler);
private ObjectNode paramSchemaFor(Method method) {
Optional<Class<?>> bodyType = firstComplexParamType(method);
return bodyType.map(schemaGenerator::toSchema).orElseGet(() -> emptyObjectSchema());
}
@@ -156,13 +137,12 @@ public class McpToolCatalog {
return out;
}
private Optional<Class<?>> firstComplexParamType(HandlerMethod handler) {
for (MethodParameter p : handler.getMethodParameters()) {
Class<?> type = p.getParameterType();
private Optional<Class<?>> firstComplexParamType(Method method) {
for (Class<?> type : method.getParameterTypes()) {
if (type.isPrimitive() || type == String.class || type.getName().startsWith("java.")) {
continue;
}
// Skip Spring-managed parameter types (HttpServletRequest, Principal, etc.).
// Skip container-managed parameter types (HttpServletRequest, Principal, etc.).
String pkg = type.getPackageName();
if (pkg.startsWith("jakarta.") || pkg.startsWith("org.springframework.")) {
continue;
@@ -220,46 +200,10 @@ public class McpToolCatalog {
log.info("MCP tool catalog AI capabilities replaced: {} entries", next.size());
}
/** Only POST/PUT endpoints are exposed as tools; DELETE and GET are excluded. */
static boolean isInvocableMethod(Set<RequestMethod> methods) {
return methods.contains(RequestMethod.POST) || methods.contains(RequestMethod.PUT);
}
private static String extractOpId(String pattern, OperationCategory category) {
if (category.urlPrefix() == null || !pattern.startsWith(category.urlPrefix())) {
return null;
}
String tail = pattern.substring(category.urlPrefix().length());
if (tail.isBlank() || tail.contains("/") || tail.contains("{")) {
// Skip nested paths and path-variable templates.
return null;
}
return tail;
}
private static String prettifyOpId(String id) {
return id.replace('-', ' ');
}
private static Set<String> extractPatterns(RequestMappingInfo info) {
try {
Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
Object result = getDirectPaths.invoke(info);
if (result instanceof Set<?> set) {
Set<String> patterns = new TreeSet<>();
for (Object v : set) {
if (v instanceof String s) {
patterns.add(s);
}
}
return patterns;
}
} catch (Exception e) {
log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
}
return Collections.emptySet();
}
public Map<String, OperationMeta> snapshotPdfOps() {
return new LinkedHashMap<>(pdfOps);
}
@@ -1,6 +1,6 @@
package stirling.software.proprietary.mcp.catalog;
import org.springframework.web.method.HandlerMethod;
import java.lang.reflect.Method;
import tools.jackson.databind.node.ObjectNode;
@@ -13,7 +13,12 @@ public record OperationMeta(
String requiredScope,
Target target,
String endpointPath,
HandlerMethod handlerMethod) {
// TODO: Migration required - was org.springframework.web.method.HandlerMethod (Spring MVC,
// no Quarkus equivalent). Replaced with the underlying java.lang.reflect.Method. The
// collaborator McpToolCatalog must be updated to discover JAX-RS resource methods (e.g. via
// RESTEasy Reactive ResourceScanningSupport / jakarta.ws.rs annotations) instead of
// Spring's RequestMappingHandlerMapping, and pass a reflect.Method here.
Method handlerMethod) {
public enum Target {
JAVA_ENDPOINT,
@@ -8,7 +8,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.MultipartFile;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -12,13 +12,12 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import io.quarkus.runtime.StartupEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import lombok.extern.slf4j.Slf4j;
@@ -36,8 +35,11 @@ import tools.jackson.databind.node.ObjectNode;
* {@link McpToolCatalog}.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
// TODO: Migration required - @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
// has no direct CDI equivalent. The onReady() observer below guards on a runtime config toggle
// instead; consider @io.quarkus.arc.lookup.LookupIfProperty / a build-time profile if the bean
// itself should be excluded.
public class EngineCapabilityClient {
private final ApplicationProperties applicationProperties;
@@ -70,8 +72,7 @@ public class EngineCapabilityClient {
});
}
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
public void onReady(@Observes StartupEvent event) {
long minutes =
Math.max(1, applicationProperties.getMcp().getEngineCapabilityRefreshMinutes());
// First refresh immediately, then on the configured cadence.
@@ -4,19 +4,12 @@ import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@@ -28,12 +21,11 @@ import stirling.software.proprietary.security.service.UserService;
* that user with the MCP scopes.
*/
@Slf4j
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
public class McpApiKeyAuthFilter implements Filter {
private static final List<GrantedAuthority> MCP_SCOPES =
List.of(
new SimpleGrantedAuthority("SCOPE_mcp.tools.read"),
new SimpleGrantedAuthority("SCOPE_mcp.tools.write"));
// MCP scopes granted to a request authenticated via API key.
private static final List<String> MCP_SCOPES =
List.of("SCOPE_mcp.tools.read", "SCOPE_mcp.tools.write");
private final UserService userService;
@@ -42,33 +34,35 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter {
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
// Treat an anonymous token as not authenticated so the key is still processed.
boolean unauthenticated =
existing == null
|| existing instanceof AnonymousAuthenticationToken
|| !existing.isAuthenticated();
if (unauthenticated) {
String apiKey = extractKey(request);
if (apiKey != null && !apiKey.isBlank()) {
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isPresent() && user.get().isEnabled()) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
user.get().getUsername(), null, MCP_SCOPES);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
} else {
log.warn(
"MCP access denied: presented API key did not match an active account");
}
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
// TODO: Migration required - Spring Security removed. This filter previously read the
// current Authentication from SecurityContextHolder to decide whether to process the API
// key. Quarkus has no SecurityContextHolder; the current identity is exposed via
// io.quarkus.security.identity.SecurityIdentity. With the binding below not yet wired, we
// always attempt to validate the presented key so the lookup logic is preserved.
String apiKey = extractKey(request);
if (apiKey != null && !apiKey.isBlank()) {
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isPresent() && user.get().isEnabled()) {
// TODO: Migration required - bind the resolved user + MCP_SCOPES to the request
// identity. Spring's UsernamePasswordAuthenticationToken /
// SecurityContextHolder.setContext(...) has no servlet-filter equivalent in
// Quarkus. Implement an io.quarkus.security.identity.SecurityIdentityAugmentor (or
// a custom io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism /
// IdentityProvider keyed off the X-API-KEY / Bearer credential) that produces a
// SecurityIdentity with principal=user.getUsername() and roles=MCP_SCOPES.
log.debug(
"MCP API key matched active account '{}' (identity binding pending Quarkus"
+ " SecurityIdentity migration)",
user.get().getUsername());
} else {
log.warn("MCP access denied: presented API key did not match an active account");
}
}
filterChain.doFilter(request, response);
filterChain.doFilter(servletRequest, servletResponse);
}
private String extractKey(HttpServletRequest request) {
@@ -2,16 +2,21 @@ package stirling.software.proprietary.mcp.security;
import java.util.List;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
* {@code aud} claim. Fails closed when the resource id is unset.
*
* <p>TODO: Migration required - this was a Spring Security
* {@code OAuth2TokenValidator<Jwt>}. Quarkus-oidc has no equivalent validator SPI; the standard way
* to enforce audience binding is configuration:
* {@code quarkus.oidc.token.audience=<resource-id>} (combined with
* {@code mp.jwt.verify.audiences} for smallrye-jwt). The fail-closed behaviour when no resource id
* is configured must be reproduced either by making that config mandatory or by augmenting the
* {@code io.quarkus.security.identity.SecurityIdentity} via a
* {@code SecurityIdentityAugmentor}. The pure audience-check logic below is preserved so it can be
* invoked from such an augmentor or a custom {@code jakarta.ws.rs.container.ContainerRequestFilter}.
*/
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
public class McpAudienceValidator {
private final String expectedResourceId;
@@ -19,26 +24,37 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
/**
* Validates that the supplied token audience claim contains this server's resource id.
*
* @param audience the {@code aud} claim values from the JWT
* @return a result describing success or the failure reason
*/
public Result validate(List<String> audience) {
if (expectedResourceId.isBlank()) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
"MCP server has no resource id configured; rejecting all tokens"
+ " until mcp.auth.resource-id is set.",
null));
return Result.failure(
"invalid_token",
"MCP server has no resource id configured; rejecting all tokens"
+ " until mcp.auth.resource-id is set.");
}
List<String> aud = token.getAudience();
if (aud == null || !aud.contains(expectedResourceId)) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
"Token audience does not include this server's resource id ("
+ expectedResourceId
+ ").",
null));
if (audience == null || !audience.contains(expectedResourceId)) {
return Result.failure(
"invalid_token",
"Token audience does not include this server's resource id ("
+ expectedResourceId
+ ").");
}
return Result.success();
}
/** Outcome of an audience validation, replacing Spring's OAuth2TokenValidatorResult. */
public record Result(boolean valid, String errorCode, String description) {
static Result success() {
return new Result(true, null, null);
}
static Result failure(String errorCode, String description) {
return new Result(false, errorCode, description);
}
return OAuth2TokenValidatorResult.success();
}
}
@@ -2,31 +2,38 @@ package stirling.software.proprietary.mcp.security;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.core.Response;
/**
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring
* X-Forwarded-* headers to build the public-facing metadata URL.
*
* <p>TODO: Migration required - this was a Spring Security {@code AuthenticationEntryPoint}
* (commence(...) invoked by the SecurityFilterChain on authentication failure). Quarkus has no
* SecurityFilterChain equivalent. The 401 response must instead be produced by a Quarkus auth
* mechanism / failure handler (e.g. an {@link io.quarkus.security.AuthenticationFailedException}
* mapper via a {@code jakarta.ws.rs.ext.ExceptionMapper}, or a custom HttpAuthenticationMechanism
* sendChallenge). The reusable header-building logic below has been preserved; wire
* {@link #commence(HttpServletRequest, HttpServletResponse)} into that handler.
*/
public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
@ApplicationScoped
public class McpAuthenticationEntryPoint {
private final String metadataPath;
public McpAuthenticationEntryPoint() {
this("/.well-known/oauth-protected-resource");
}
public McpAuthenticationEntryPoint(String metadataPath) {
this.metadataPath =
metadataPath == null ? "/.well-known/oauth-protected-resource" : metadataPath;
}
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
public void commence(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme());
String authority = forwardedHost(request, scheme);
@@ -34,7 +41,7 @@ public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
response.setHeader(
"WWW-Authenticate",
"Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\"");
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
response.sendError(Response.Status.UNAUTHORIZED.getStatusCode(), "Unauthorized");
}
/** host[:port] from forwarded headers when present, else the servlet host/port. */
@@ -9,12 +9,13 @@ import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
@@ -23,7 +24,11 @@ import jakarta.servlet.http.HttpServletResponse;
* Caps MCP request body size (via Content-Length and by buffering up to the cap) and rejects
* oversized bodies with a clean 413 before JSON parsing.
*/
public class McpRequestSizeFilter extends OncePerRequestFilter {
// TODO: Migration required - this filter was a Spring OncePerRequestFilter; under Quarkus
// (quarkus-undertow) register it as a jakarta.servlet.Filter via @WebFilter or a programmatic
// FilterRegistrationBean equivalent, and ensure it runs once per request and before the MCP
// endpoint. Registration ordering must be verified by the collaborator wiring the servlet filters.
public class McpRequestSizeFilter implements Filter {
private final long maxBodyBytes;
@@ -32,9 +37,11 @@ public class McpRequestSizeFilter extends OncePerRequestFilter {
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
public void doFilter(
ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
long declared = request.getContentLengthLong();
if (declared > maxBodyBytes) {
tooLarge(response);
@@ -1,38 +1,7 @@
package stirling.software.proprietary.mcp.security;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.web.cors.CorsConfigurationSource;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -42,37 +11,67 @@ import stirling.software.proprietary.security.service.UserService;
/**
* MCP security chain: validates JWTs (JWKS + RFC 8707 audience), maps scope claims to authorities,
* and fails closed when the issuer is unset.
*
* <p>TODO: Migration required - this class was a Spring Security {@code SecurityFilterChain} /
* {@code HttpSecurity} DSL configuration, which has NO direct Quarkus equivalent. The Spring
* security DSL has been removed; the equivalent behaviour must be rebuilt on Quarkus primitives:
*
* <ul>
* <li>HTTP path matching ({@code /mcp}, {@code /mcp/**}, {@code /.well-known/oauth-protected-resource})
* and authenticated-vs-permitAll policy -> declare via {@code quarkus.http.auth.permission.*}
* in application.properties (permit GET on the metadata path, authenticate the rest), or via a
* {@code jakarta.ws.rs.container.ContainerRequestFilter}.
* <li>Stateless session ({@code SessionCreationPolicy.STATELESS}) and CSRF-disabled -> Quarkus REST
* is stateless by default; no CSRF filter is added unless quarkus-csrf-reactive is enabled.
* <li>OAuth2 resource-server JWT validation (issuer/JWKS + RFC 8707 audience + scope->authority
* mapping) -> quarkus-oidc in {@code service} application type, or quarkus-smallrye-jwt for
* bearer validation. Wire {@code quarkus.oidc.auth-server-url}=issuer-uri,
* {@code quarkus.oidc.token.audience}=resource-id; map the {@code scope} claim to roles via a
* {@code io.quarkus.security.identity.SecurityIdentityAugmentor} (replacing
* {@code JwtGrantedAuthoritiesConverter} with prefix {@code SCOPE_} and the {@code AUDIENCE_}
* authorities added below). The fail-closed behaviour when issuer-uri is blank is preserved by
* NOT configuring quarkus.oidc when blank (every bearer request then 401s).
* <li>API-key mode ({@code mcp.auth.mode=apikey}) -> register {@link McpApiKeyAuthFilter} as a
* {@code jakarta.ws.rs.container.ContainerRequestFilter @Provider} (or a jakarta.servlet
* Filter via quarkus-undertow) that validates the X-API-KEY / Bearer key against
* {@link UserService} and returns the 401 + {@code WWW-Authenticate} response below.
* <li>RFC 9728 protected-resource metadata ({@code /.well-known/oauth-protected-resource} with
* resource/authorizationServer/scopes mcp.tools.read + mcp.tools.write) -> serve from a small
* JAX-RS resource returning the JSON document.
* <li>Pre-auth body-size cap ({@link McpRequestSizeFilter}) and post-auth user binding
* ({@link McpUserBindingFilter}) -> register as ContainerRequestFilters with explicit
* {@code @Priority} so size-cap runs before auth and user-binding runs after; ordering matters.
* <li>Reused CORS source ({@code corsConfigurationSource}) -> configure via {@code quarkus.http.cors.*}.
* </ul>
*
* The helper components ({@link McpApiKeyAuthFilter}, {@link McpUserBindingFilter},
* {@link McpRequestSizeFilter}, {@link McpAudienceValidator}, {@link McpAuthenticationEntryPoint})
* are preserved unchanged and should be wired in by the new Quarkus security plumbing. The
* configuration-reading and fail-closed warning logic below is kept verbatim.
*/
@Slf4j
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
// TODO: Migration required - @Order(Ordered.HIGHEST_PRECEDENCE) and
// @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") were removed. Gate MCP
// security wiring on the runtime property mcp.enabled=true (the value is a runtime toggle, not a
// build profile, so prefer a runtime guard in the new ContainerRequestFilter/augmentor rather than
// @IfBuildProfile). Filter ordering (highest precedence) must be re-expressed via JAX-RS @Priority
// or quarkus.http.auth.permission ordering.
public class McpSecurityConfig {
private final ApplicationProperties applicationProperties;
private final UserService userService;
// Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean
// exists.
private final ObjectProvider<CorsConfigurationSource> corsConfigurationSource;
// TODO: Migration required - UserService was injected @Lazy to break a circular wiring with the
// security chain. With the Spring chain removed, inject it directly into the new API-key /
// user-binding ContainerRequestFilters instead of holding it here.
private final UserService userService;
private static final String BASE_PATH = "/mcp";
public McpSecurityConfig(
ApplicationProperties applicationProperties,
@Lazy UserService userService,
ObjectProvider<CorsConfigurationSource> corsConfigurationSource) {
ApplicationProperties applicationProperties, UserService userService) {
this.applicationProperties = applicationProperties;
this.userService = userService;
this.corsConfigurationSource = corsConfigurationSource;
}
/** Enable CORS on the MCP chain using the app-wide source when available. */
private void applyCors(HttpSecurity http) throws Exception {
CorsConfigurationSource source = corsConfigurationSource.getIfAvailable();
if (source != null) {
http.cors(cors -> cors.configurationSource(source));
}
}
@PostConstruct
@@ -98,165 +97,49 @@ public class McpSecurityConfig {
}
}
@Bean
@Order(0)
SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http, JwtDecoder mcpJwtDecoder)
throws Exception {
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
if (isApiKeyMode()) {
return apiKeyFilterChain(http);
}
return oauthFilterChain(http, mcpJwtDecoder, auth);
}
private boolean isApiKeyMode() {
return "apikey".equalsIgnoreCase(applicationProperties.getMcp().getAuth().getMode());
}
/**
* API-key chain: a Stirling per-user API key is validated by {@link McpApiKeyAuthFilter};
* otherwise 401.
*/
private SecurityFilterChain apiKeyFilterChain(HttpSecurity http) throws Exception {
applyCors(http);
http.securityMatcher(BASE_PATH, BASE_PATH + "/**")
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC API authenticated by an
// out-of-band X-API-KEY header (or Authorization: Bearer <key>). No cookies, no
// session, no form submissions; a browser cannot trick a victim into sending the
// header cross-origin, so the CSRF attack model does not apply. CodeQL flags this
// generically; the SessionCreationPolicy.STATELESS below is the relevant guarantee.
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.exceptionHandling(
e ->
e.authenticationEntryPoint(
(request, response, ex) -> {
response.setStatus(401);
response.setHeader(
"WWW-Authenticate",
"Bearer realm=\"Stirling MCP (API key)\"");
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"unauthorized\",\"message\":\"Provide a valid Stirling API key via the X-API-KEY header (or Authorization: Bearer <key>).\"}");
}))
.addFilterBefore(
new McpRequestSizeFilter(
applicationProperties.getMcp().getMaxRequestBytes()),
AuthorizationFilter.class)
// Authenticate before the anonymous filter sets an anonymous token.
.addFilterBefore(
new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class);
return http.build();
}
// TODO: Migration required - the following describe the original chain wiring so the Quarkus
// re-implementation can reproduce it faithfully. They are documented as constants/notes rather
// than executable HttpSecurity DSL (which does not exist in Quarkus).
/** OAuth2 resource-server chain (JWT, RFC 8707 audience, RFC 9728 metadata). */
private SecurityFilterChain oauthFilterChain(
HttpSecurity http, JwtDecoder mcpJwtDecoder, ApplicationProperties.Mcp.Auth auth)
throws Exception {
String metadataPath = "/.well-known/oauth-protected-resource";
applyCors(http);
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
// session, no form submissions; CSRF requires browser-attached ambient credentials
// and the bearer token is supplied per-request by the MCP client. CodeQL flags
// this generically; the SessionCreationPolicy.STATELESS below is the actual
// guarantee, and the .well-known metadata endpoint only serves GET.
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
a ->
a.requestMatchers(HttpMethod.GET, metadataPath)
.permitAll()
.anyRequest()
.authenticated())
// Cap body size pre-auth, then bind the validated token to a Stirling user after
// the bearer filter.
.addFilterBefore(
new McpRequestSizeFilter(
applicationProperties.getMcp().getMaxRequestBytes()),
BearerTokenAuthenticationFilter.class)
.addFilterAfter(
new McpUserBindingFilter(
userService,
auth.getUsernameClaim(),
auth.isRequireExistingAccount()),
BearerTokenAuthenticationFilter.class)
.oauth2ResourceServer(
oauth2 ->
oauth2.authenticationEntryPoint(
new McpAuthenticationEntryPoint(metadataPath))
// RFC 9728 protected-resource metadata for OAuth discovery.
.protectedResourceMetadata(
prm ->
prm.protectedResourceMetadataCustomizer(
builder -> {
if (!auth.getResourceId()
.isBlank()) {
builder.resource(
auth
.getResourceId());
}
if (!auth.getIssuerUri()
.isBlank()) {
builder.authorizationServer(
auth
.getIssuerUri());
}
builder.scope("mcp.tools.read");
builder.scope(
"mcp.tools.write");
}))
.jwt(
jwt ->
jwt.decoder(mcpJwtDecoder)
.jwtAuthenticationConverter(
mcpJwtAuthenticationConverter())));
return http.build();
}
// API-key chain (mcp.auth.mode=apikey): securityMatcher(BASE_PATH, BASE_PATH + "/**");
// CSRF disabled (stateless JSON-RPC, X-API-KEY / Bearer <key>, no cookies/session);
// SessionCreationPolicy.STATELESS; anyRequest().authenticated();
// authenticationEntryPoint -> 401 with header WWW-Authenticate: Bearer realm="Stirling MCP
// (API key)", Content-Type application/json, body
// {"error":"unauthorized","message":"Provide a valid Stirling API key via the X-API-KEY
// header (or Authorization: Bearer <key>)."};
// addFilterBefore(new McpRequestSizeFilter(maxRequestBytes), AuthorizationFilter.class);
// addFilterBefore(new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class)
// (authenticate before any anonymous token is set).
@Bean
JwtDecoder mcpJwtDecoder() {
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
if (auth.getIssuerUri().isBlank()) {
// Fail-closed decoder: rejects every token until the issuer is set.
return token -> {
throw new org.springframework.security.oauth2.jwt.BadJwtException(
"mcp.auth.issuer-uri is not configured");
};
}
String jwksUri = auth.getJwksUri();
NimbusJwtDecoder decoder =
jwksUri.isBlank()
? NimbusJwtDecoder.withIssuerLocation(auth.getIssuerUri()).build()
: NimbusJwtDecoder.withJwkSetUri(jwksUri).build();
OAuth2TokenValidator<Jwt> defaultValidators =
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
OAuth2TokenValidator<Jwt> combined =
new DelegatingOAuth2TokenValidator<>(
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
decoder.setJwtValidator(combined);
return decoder;
}
// OAuth2 resource-server chain: metadataPath = "/.well-known/oauth-protected-resource";
// securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath);
// CSRF disabled; SessionCreationPolicy.STATELESS;
// GET metadataPath permitAll, anyRequest().authenticated();
// addFilterBefore(new McpRequestSizeFilter(maxRequestBytes), BearerTokenAuthenticationFilter.class);
// addFilterAfter(new McpUserBindingFilter(userService, auth.getUsernameClaim(),
// auth.isRequireExistingAccount()), BearerTokenAuthenticationFilter.class);
// oauth2ResourceServer: authenticationEntryPoint = new McpAuthenticationEntryPoint(metadataPath);
// RFC 9728 protected-resource metadata -> resource=auth.getResourceId() (if non-blank),
// authorizationServer=auth.getIssuerUri() (if non-blank), scopes mcp.tools.read +
// mcp.tools.write;
// jwt: decoder=mcpJwtDecoder, jwtAuthenticationConverter=mcpJwtAuthenticationConverter.
private Converter<Jwt, AbstractAuthenticationToken> mcpJwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
scopes.setAuthorityPrefix("SCOPE_");
scopes.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(
jwt -> {
Collection<GrantedAuthority> out = new ArrayList<>(scopes.convert(jwt));
List<String> aud = jwt.getAudience();
if (aud != null) {
for (String a : aud) {
out.add(new SimpleGrantedAuthority("AUDIENCE_" + a));
}
}
return out;
});
return converter;
}
// JWT decoder (was @Bean JwtDecoder mcpJwtDecoder): fail-closed when auth.getIssuerUri() is
// blank (reject every token); else NimbusJwtDecoder.withJwkSetUri(jwksUri) when jwks-uri set,
// otherwise NimbusJwtDecoder.withIssuerLocation(issuerUri); validators =
// DelegatingOAuth2TokenValidator(default-with-issuer, new McpAudienceValidator(resourceId)).
// -> Replace with quarkus-oidc/quarkus-smallrye-jwt config (auth-server-url=issuer-uri,
// token.audience=resource-id, jwks via discovery or quarkus.oidc.jwks-path). Keep
// McpAudienceValidator's audience logic in a custom validator if OIDC's audience check is
// insufficient. Do NOT configure when issuer-uri is blank to preserve fail-closed behaviour.
// JWT authentication converter (scope -> authority mapping): map the "scope" claim to authorities
// with prefix "SCOPE_", and additionally add "AUDIENCE_<aud>" for each audience entry on the
// token. -> Re-implement in a io.quarkus.security.identity.SecurityIdentityAugmentor that adds
// roles "SCOPE_<scope>" and "AUDIENCE_<aud>" to the SecurityIdentity.
}
@@ -3,16 +3,11 @@ package stirling.software.proprietary.mcp.security;
import java.io.IOException;
import java.util.Optional;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -28,9 +23,21 @@ import tools.jackson.databind.node.ObjectNode;
* Binds an MCP-validated JWT to a provisioned Stirling user: optionally rejects subjects with no
* enabled account, then rebinds the principal to the canonical Stirling username (scope authorities
* only) so audit/metering attribute correctly.
*
* <p>TODO: Migration required - this was a Spring Security {@code OncePerRequestFilter} that read
* and rewrote the {@code SecurityContextHolder} ({@code JwtAuthenticationToken}/{@code Jwt}).
* Quarkus has no global mutable security context; the canonical replacement is a
* {@code io.quarkus.security.identity.SecurityIdentityAugmentor} that runs after quarkus-oidc/
* quarkus-smallrye-jwt validates the bearer token, reads the username claim from the
* {@code JsonWebToken}, looks up the Stirling account via {@link UserService}, and rebuilds the
* {@code SecurityIdentity} with the canonical principal name while preserving the original scope
* roles. The account-lookup and reject logic below is preserved; only the identity read/rebind and
* the request rejection plumbing still need to be wired to the augmentor (or to a
* {@code jakarta.ws.rs.container.ContainerRequestFilter @Provider} that aborts with 403). Until
* then this filter passes every request through unchanged.
*/
@Slf4j
public class McpUserBindingFilter extends OncePerRequestFilter {
public class McpUserBindingFilter implements Filter {
private static final ObjectMapper MAPPER = new ObjectMapper();
@@ -47,15 +54,19 @@ public class McpUserBindingFilter extends OncePerRequestFilter {
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain)
throws ServletException, IOException {
Authentication current = SecurityContextHolder.getContext().getAuthentication();
HttpServletResponse response = (HttpServletResponse) res;
// Only act on a JWT-authenticated request; everything else passes through.
if (current instanceof JwtAuthenticationToken jwtAuth && jwtAuth.isAuthenticated()) {
Jwt jwt = jwtAuth.getToken();
String username = jwt.getClaimAsString(usernameClaim);
// TODO: Migration required - extract the validated JWT and its claims from the Quarkus
// SecurityIdentity / JsonWebToken instead of Spring's SecurityContextHolder. The block
// below preserves the original binding logic but cannot run until that wiring exists, so
// for now every request passes through untouched.
boolean jwtAuthenticated = false; // TODO: derive from injected SecurityIdentity / JWT
if (jwtAuthenticated) {
// TODO: Migration required - read the claim value from the validated token, e.g.
// jsonWebToken.getClaim(usernameClaim). Placeholder keeps the surrounding logic intact.
String username = null; // TODO: jwt.getClaim(usernameClaim)
if (username == null || username.isBlank()) {
reject(
@@ -85,17 +96,15 @@ public class McpUserBindingFilter extends OncePerRequestFilter {
boundUsername = account.get().getUsername();
}
// Rebind to the Stirling username, carrying only the OAuth scope authorities.
UsernamePasswordAuthenticationToken bound =
new UsernamePasswordAuthenticationToken(
boundUsername, null, jwtAuth.getAuthorities());
bound.setDetails(jwtAuth.getDetails());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(bound);
SecurityContextHolder.setContext(context);
// TODO: Migration required - rebind to the Stirling username, carrying only the OAuth
// scope authorities. With quarkus-oidc/smallrye-jwt this is done by a
// SecurityIdentityAugmentor that returns a new SecurityIdentity whose principal name is
// boundUsername and whose roles are the original token scopes. boundUsername is computed
// above and ready to feed into that augmentor.
log.debug("MCP user binding resolved canonical username: {}", boundUsername);
}
filterChain.doFilter(request, response);
filterChain.doFilter(req, res);
}
/** Strip CR/LF so a crafted claim value can't forge log lines. */
@@ -104,7 +113,10 @@ public class McpUserBindingFilter extends OncePerRequestFilter {
}
private void reject(HttpServletResponse response, String message) throws IOException {
SecurityContextHolder.clearContext();
// TODO: Migration required - on the Quarkus path, rejection should clear/deny the
// SecurityIdentity (augmentor throws AuthenticationFailedException) or the
// ContainerRequestFilter should abortWith(Response.status(403)...). The 403 JSON body below
// is preserved as the intended response shape.
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
ObjectNode body = MAPPER.createObjectNode();
@@ -2,7 +2,7 @@ package stirling.software.proprietary.mcp.tools;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import jakarta.enterprise.inject.Instance;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
@@ -22,13 +22,13 @@ import tools.jackson.databind.node.ObjectNode;
abstract class AbstractCategoryTool implements McpTool {
protected final ObjectMapper mapper;
protected final ObjectProvider<McpToolCatalog> catalogProvider;
protected final ObjectProvider<McpOperationExecutor> executorProvider;
protected final Instance<McpToolCatalog> catalogProvider;
protected final Instance<McpOperationExecutor> executorProvider;
protected AbstractCategoryTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
Instance<McpToolCatalog> catalog,
Instance<McpOperationExecutor> executor) {
this.mapper = mapper;
this.catalogProvider = catalog;
this.executorProvider = executor;
@@ -37,7 +37,7 @@ abstract class AbstractCategoryTool implements McpTool {
protected abstract OperationCategory category();
protected List<OperationMeta> enabledOperations() {
McpToolCatalog catalog = catalogProvider.getIfAvailable();
McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return List.of();
}
@@ -104,7 +104,7 @@ abstract class AbstractCategoryTool implements McpTool {
return operationListError(null);
}
String opId = opNode.asText();
McpToolCatalog catalog = catalogProvider.getIfAvailable();
McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
@@ -118,7 +118,8 @@ abstract class AbstractCategoryTool implements McpTool {
mapper,
"Insufficient scope: this operation requires '" + meta.requiredScope() + "'.");
}
McpOperationExecutor executor = executorProvider.getIfAvailable();
McpOperationExecutor executor =
executorProvider.isResolvable() ? executorProvider.get() : null;
if (executor == null) {
return McpResponses.error(mapper, "MCP execution is not available.");
}
@@ -1,8 +1,9 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import io.quarkus.arc.lookup.LookupIfProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
@@ -15,14 +16,15 @@ import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/** Returns the JSON Schema for one operation's parameters, from the live {@link McpToolCatalog}. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class DescribeOperationTool implements McpTool {
private final ObjectMapper mapper;
private final ObjectProvider<McpToolCatalog> catalogProvider;
private final Instance<McpToolCatalog> catalogProvider;
public DescribeOperationTool(ObjectMapper mapper, ObjectProvider<McpToolCatalog> catalog) {
@Inject
public DescribeOperationTool(ObjectMapper mapper, Instance<McpToolCatalog> catalog) {
this.mapper = mapper;
this.catalogProvider = catalog;
}
@@ -63,7 +65,8 @@ public class DescribeOperationTool implements McpTool {
return McpResponses.error(mapper, "Missing required argument: operation");
}
String opId = opNode.asText();
McpToolCatalog catalog = catalogProvider.getIfAvailable();
McpToolCatalog catalog =
catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
@@ -1,25 +1,24 @@
package stirling.software.proprietary.mcp.tools;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientResponseException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.io.InputStreamResource;
import stirling.software.common.model.io.Resource;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
@@ -35,8 +34,11 @@ import tools.jackson.databind.node.ObjectNode;
* to the Stirling endpoint over the loopback via {@link InternalApiClient}, and stores the result.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
// TODO: Migration required - the Spring @ConditionalOnProperty(name = "mcp.enabled",
// havingValue = "true") guard is not directly portable. For a build-time toggle use
// @io.quarkus.arc.lookup.LookupIfProperty(name = "mcp.enabled", stringValue = "true") on the
// injection points, or gate the call sites at runtime; this bean is otherwise always created.
public class McpOperationExecutor {
private final ObjectMapper mapper;
@@ -95,11 +97,13 @@ public class McpOperationExecutor {
inputName = fileName != null ? fileName : "input.pdf";
}
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", bytesResource(inputBytes, inputName));
// The migrated InternalApiClient takes a Map<String, List<Object>> (replacing Spring's
// MultiValueMap) and returns a jakarta.ws.rs.core.Response.
Map<String, List<Object>> body = new LinkedHashMap<>();
addToBody(body, "fileInput", bytesResource(inputBytes, inputName));
addParameters(body, arguments == null ? null : arguments.get("parameters"));
ResponseEntity<Resource> response;
Response response;
try {
response = internalApiClient.post(meta.endpointPath(), body);
} catch (InternalApiTimeoutException e) {
@@ -109,34 +113,39 @@ public class McpOperationExecutor {
+ " timed out after "
+ e.getReadTimeout().toSeconds()
+ "s. Try a smaller file or a different approach.");
} catch (RestClientResponseException e) {
log.warn(
"MCP {} upstream error: HTTP {} - {}",
meta.id(),
e.getStatusCode().value(),
snippet(e.getResponseBodyAsString()));
return McpResponses.error(
mapper, meta.id() + " failed: HTTP " + e.getStatusCode().value() + ".");
} catch (SecurityException e) {
return McpResponses.error(
mapper, meta.id() + " endpoint is not permitted for MCP dispatch.");
} catch (UncheckedIOException e) {
log.warn("MCP execution of {} failed", meta.id(), e);
return McpResponses.error(
mapper, meta.id() + " failed unexpectedly. See server logs for details.");
} catch (RuntimeException e) {
log.warn("MCP execution of {} failed", meta.id(), e);
return McpResponses.error(
mapper, meta.id() + " failed unexpectedly. See server logs for details.");
}
// Spring's RestTemplate threw RestClientResponseException on non-2xx upstream responses;
// the migrated HttpClient-based InternalApiClient returns the upstream status as a Response.
int status = response.getStatus();
if (status < 200 || status >= 300) {
String responseBody = readErrorBody(response);
log.warn("MCP {} upstream error: HTTP {} - {}", meta.id(), status, snippet(responseBody));
return McpResponses.error(mapper, meta.id() + " failed: HTTP " + status + ".");
}
return buildResult(meta, response);
}
private ObjectNode buildResult(OperationMeta meta, ResponseEntity<Resource> response) {
Resource body = response.getBody();
private ObjectNode buildResult(OperationMeta meta, Response response) {
Resource body = (Resource) response.getEntity();
if (body == null) {
return McpResponses.error(mapper, meta.id() + " returned an empty response.");
}
MediaType contentType = response.getHeaders().getContentType();
MediaType contentType = response.getMediaType();
// A JSON body is a structured report (e.g. get-info), not a file.
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
if (contentType != null && MediaType.APPLICATION_JSON_TYPE.isCompatible(contentType)) {
try (InputStream is = body.getInputStream()) {
return McpResponses.text(
mapper, new String(is.readAllBytes(), StandardCharsets.UTF_8));
@@ -152,7 +161,7 @@ public class McpOperationExecutor {
String mimeType =
contentType != null
? contentType.toString()
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
: MediaType.APPLICATION_OCTET_STREAM;
long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes();
try {
long size = body.contentLength();
@@ -207,7 +216,7 @@ public class McpOperationExecutor {
}
}
private void addParameters(MultiValueMap<String, Object> body, JsonNode params) {
private void addParameters(Map<String, List<Object>> body, JsonNode params) {
if (params == null || !params.isObject()) {
return;
}
@@ -220,31 +229,54 @@ public class McpOperationExecutor {
}
if (value instanceof List<?> list) {
if (containsStructured(list)) {
body.add(entry.getKey(), mapper.writeValueAsString(list));
addToBody(body, entry.getKey(), mapper.writeValueAsString(list));
} else {
list.forEach(item -> body.add(entry.getKey(), item));
list.forEach(item -> addToBody(body, entry.getKey(), item));
}
} else if (value instanceof Map<?, ?>) {
body.add(entry.getKey(), mapper.writeValueAsString(value));
addToBody(body, entry.getKey(), mapper.writeValueAsString(value));
} else {
body.add(entry.getKey(), value);
addToBody(body, entry.getKey(), value);
}
}
}
/**
* Add a value to a multi-value form body. The body is a {@code Map<String, List<Object>>}
* (replacing Spring's {@code MultiValueMap}) because the migrated {@link InternalApiClient}
* encodes the multipart request manually.
*/
private static void addToBody(Map<String, List<Object>> body, String key, Object value) {
body.computeIfAbsent(key, k -> new java.util.ArrayList<>()).add(value);
}
private static boolean containsStructured(List<?> list) {
return list.stream().anyMatch(item -> item instanceof Map<?, ?> || item instanceof List<?>);
}
private static Resource bytesResource(byte[] bytes, String filename) {
return new ByteArrayResource(bytes) {
return new InputStreamResource(new ByteArrayInputStream(bytes), filename) {
@Override
public String getFilename() {
return filename;
public long contentLength() {
return bytes.length;
}
};
}
private static String readErrorBody(Response response) {
try {
Object entity = response.getEntity();
if (entity instanceof Resource resource) {
try (InputStream is = resource.getInputStream()) {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
return entity != null ? String.valueOf(entity) : null;
} catch (IOException e) {
return null;
}
}
private static String snippet(String body) {
if (body == null || body.isBlank()) {
return "(no body)";
@@ -3,9 +3,10 @@ package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import io.quarkus.arc.lookup.LookupIfProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
@@ -26,18 +27,19 @@ import tools.jackson.databind.node.ObjectNode;
* capabilities manifest.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingAiTool implements McpTool {
private final ObjectMapper mapper;
private final ObjectProvider<McpToolCatalog> catalogProvider;
private final ObjectProvider<AiEngineClient> engineClientProvider;
private final Instance<McpToolCatalog> catalogProvider;
private final Instance<AiEngineClient> engineClientProvider;
@Inject
public StirlingAiTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<AiEngineClient> engineClient) {
Instance<McpToolCatalog> catalog,
Instance<AiEngineClient> engineClient) {
this.mapper = mapper;
this.catalogProvider = catalog;
this.engineClientProvider = engineClient;
@@ -99,7 +101,7 @@ public class StirlingAiTool implements McpTool {
return McpResponses.error(mapper, "Missing required argument: operation");
}
String opId = opNode.asText();
McpToolCatalog catalog = catalogProvider.getIfAvailable();
McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
@@ -117,7 +119,8 @@ public class StirlingAiTool implements McpTool {
mapper,
"Insufficient scope: this capability requires '" + meta.requiredScope() + "'.");
}
AiEngineClient client = engineClientProvider.getIfAvailable();
AiEngineClient client =
engineClientProvider.isResolvable() ? engineClientProvider.get() : null;
if (client == null) {
return McpResponses.error(
mapper, "AI engine client is not configured - enable aiEngine in settings.");
@@ -140,7 +143,7 @@ public class StirlingAiTool implements McpTool {
}
private List<OperationMeta> aiOps() {
McpToolCatalog catalog = catalogProvider.getIfAvailable();
McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null;
if (catalog == null) {
return List.of();
}
@@ -1,8 +1,9 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
@@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/convert/*} namespace as a single MCP tool. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingConvertTool extends AbstractCategoryTool {
public StirlingConvertTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
Instance<McpToolCatalog> catalog,
Instance<McpOperationExecutor> executor) {
super(mapper, catalog, executor);
}
@@ -3,9 +3,10 @@ package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import java.util.Base64;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.MediaType;
import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
@@ -20,8 +21,8 @@ import tools.jackson.databind.node.ObjectNode;
* Fetches a stored file's content by fileId, returned inline as base64. For large results that were
* not returned inline by an operation.
*/
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingDownloadTool implements McpTool {
private final ObjectMapper mapper;
@@ -102,7 +103,7 @@ public class StirlingDownloadTool implements McpTool {
McpResponses.resourceBlock(
mapper,
"stirling://file/" + fileId,
MediaType.APPLICATION_OCTET_STREAM_VALUE,
MediaType.APPLICATION_OCTET_STREAM,
Base64.getEncoder().encodeToString(bytes)));
} catch (SecurityException e) {
return McpResponses.error(mapper, "Unknown or inaccessible fileId '" + fileId + "'.");
@@ -1,8 +1,9 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
@@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/misc/*} namespace as a single MCP tool. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingMiscTool extends AbstractCategoryTool {
public StirlingMiscTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
Instance<McpToolCatalog> catalog,
Instance<McpOperationExecutor> executor) {
super(mapper, catalog, executor);
}
@@ -1,8 +1,9 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
@@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/general/*} (page operations) namespace as a single MCP tool. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingPagesTool extends AbstractCategoryTool {
public StirlingPagesTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
Instance<McpToolCatalog> catalog,
Instance<McpOperationExecutor> executor) {
super(mapper, catalog, executor);
}
@@ -1,8 +1,10 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import io.quarkus.arc.lookup.LookupIfProperty;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
@@ -10,14 +12,15 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/security/*} namespace as a single MCP tool. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingSecurityTool extends AbstractCategoryTool {
@Inject
public StirlingSecurityTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
Instance<McpToolCatalog> catalog,
Instance<McpOperationExecutor> executor) {
super(mapper, catalog, executor);
}
@@ -2,8 +2,10 @@ package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import io.quarkus.arc.lookup.LookupIfProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
@@ -20,13 +22,14 @@ import tools.jackson.databind.node.ObjectNode;
* most operations accept the file inline via their {@code file} argument.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
@ApplicationScoped
@LookupIfProperty(name = "mcp.enabled", stringValue = "true")
public class StirlingUploadTool implements McpTool {
private final ObjectMapper mapper;
private final FileStorage fileStorage;
@Inject
public StirlingUploadTool(ObjectMapper mapper, FileStorage fileStorage) {
this.mapper = mapper;
this.fileStorage = fileStorage;
@@ -1,11 +1,11 @@
package stirling.software.proprietary.model.api.ai;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.core.MediaType;
import stirling.software.common.model.MultipartFile;
import lombok.Data;
@@ -16,7 +16,7 @@ public class AiWorkflowFileInput {
@NotNull
@Schema(
description = "The input PDF file",
contentMediaType = MediaType.APPLICATION_PDF_VALUE,
contentMediaType = MediaType.APPLICATION_PDF,
format = "binary")
private MultipartFile fileInput;
}
@@ -2,8 +2,6 @@ package stirling.software.proprietary.model.api.audit;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@@ -20,11 +18,12 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@EqualsAndHashCode
public class AuditDateExportRequest {
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
// TODO: Migration required - Spring @DateTimeFormat(iso = ISO.DATE) removed; JAX-RS binds
// LocalDate via its default ISO-8601 (yyyy-MM-dd) ParamConverter, so ISO.DATE form values
// still bind. If a non-ISO format is ever needed, register a jakarta.ws.rs.ext.ParamConverter.
@Schema(description = "Start date for the export range", example = "2025-01-01")
private LocalDate startDate;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Schema(description = "End date for the export range", example = "2025-12-31")
private LocalDate endDate;
}
@@ -2,11 +2,14 @@ package stirling.software.proprietary.policy.config;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.smallrye.config.SmallRyeConfig;
import org.eclipse.microprofile.config.Config;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
@@ -34,7 +37,7 @@ import stirling.software.proprietary.policy.model.Policy;
* root. (Symlink escape is not defended here; an operator who configures an allowed root containing
* a symlink to a sensitive location is trusted.)
*/
@Component
@ApplicationScoped
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -43,8 +46,12 @@ public class FolderAccessGuard {
private final List<Path> allowedRoots;
private final List<Path> protectedRoots;
public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) {
this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
@Inject
public FolderAccessGuard(ApplicationProperties applicationProperties, Config config) {
// Spring's Environment.getActiveProfiles() maps to SmallRye's profile list; the "saas"
// build/runtime profile is matched the same way Spring matched the "saas" Spring profile.
this.saasActive =
config.unwrap(SmallRyeConfig.class).getProfiles().contains("saas");
this.allowedRoots =
normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots());
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
@@ -1,39 +1,42 @@
package stirling.software.proprietary.policy.controller;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
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 org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.jboss.resteasy.reactive.server.multipart.FormValue;
import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput;
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 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.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.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
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.service.UserServiceInterface;
import stirling.software.common.util.TempFile;
@@ -66,25 +69,27 @@ import tools.jackson.databind.ObjectMapper;
* the file ids in the run view.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/policies")
@ApplicationScoped
@jakarta.ws.rs.Path("/api/v1/policies")
@Hidden
@PremiumEndpoint
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
public class PolicyController {
private final PolicyRunner policyRunner;
private final PolicyRunRegistry runRegistry;
private final PolicyStore policyStore;
private final PolicyValidator policyValidator;
private final FolderAccessGuard folderAccessGuard;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
@Inject PolicyRunner policyRunner;
@Inject PolicyRunRegistry runRegistry;
@Inject PolicyStore policyStore;
@Inject PolicyValidator policyValidator;
@Inject FolderAccessGuard folderAccessGuard;
@Inject UserServiceInterface userService;
@Inject ApplicationProperties applicationProperties;
@Inject ObjectMapper objectMapper;
@Inject TempFileManager tempFileManager;
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@POST
@jakarta.ws.rs.Path("/run")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Run a tool pipeline",
description =
@@ -93,34 +98,40 @@ public class PolicyController {
+ " 'company-logo'), and a JSON pipeline definition ('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(
@RequestParam("json") String json, MultipartHttpServletRequest request)
throws IOException {
public Response run(MultipartFormDataInput request) throws IOException {
String json = formValue(request, "json");
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
String runId =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
return Response.status(Response.Status.ACCEPTED)
.entity(new JobResponse<>(true, runId, null))
.build();
}
@PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@POST
@jakarta.ws.rs.Path("/run/stream")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.SERVER_SENT_EVENTS)
@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(
@RequestParam("json") String json, MultipartHttpServletRequest request)
public void runStream(
MultipartFormDataInput request, @Context SseEventSink eventSink, @Context Sse sse)
throws IOException {
String json = formValue(request, "json");
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
SseEmitter emitter =
new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs());
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
// TODO: Migration required - Spring's SseEmitter supported a configurable timeout
// (applicationProperties.getPolicies().getStreamTimeoutMs()). JAX-RS SseEventSink has no
// per-sink timeout; configure via quarkus.http.* / a reverse proxy if a hard cap is needed.
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
PolicyRunHandle handle =
policyRunner.runAdHoc(definition, inputs, streamListener(eventSink, sse));
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
// engine's worker thread after the run is done, so this never races the step events.
handle.completion()
@@ -128,46 +139,51 @@ public class PolicyController {
(run, throwable) -> {
if (throwable != null) {
sendEvent(
emitter,
eventSink,
sse,
"failed",
Map.of("message", throwable.getMessage()));
} else {
sendEvent(emitter, terminalEventName(run), PolicyRunView.of(run));
sendEvent(
eventSink, sse, terminalEventName(run), PolicyRunView.of(run));
}
emitter.complete();
eventSink.close();
});
return emitter;
}
@GetMapping("/run/{runId}")
@GET
@jakarta.ws.rs.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.notFound().build();
return Response.status(Response.Status.NOT_FOUND).build();
}
return ResponseEntity.ok(PolicyRunView.of(run));
return Response.ok(PolicyRunView.of(run)).build();
}
// --- Policy management ---
@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 String json) {
public Response savePolicy(String json) {
Policy policy = parsePolicy(json);
requireAuthorizedForFolderAccess(policy);
try {
policyValidator.validate(policy);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST);
}
return ResponseEntity.ok(policyStore.save(policy));
return Response.ok(policyStore.save(policy)).build();
}
/**
@@ -184,36 +200,43 @@ public class PolicyController {
return;
}
if (!userService.isCurrentUserAdmin()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Folder sources and outputs may only be configured by an administrator");
throw new WebApplicationException(
"Folder sources and outputs may only be configured by an administrator",
Response.Status.FORBIDDEN);
}
}
@GetMapping
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List policies")
public List<Policy> listPolicies() {
return policyStore.all();
}
@GetMapping("/{policyId}")
@GET
@jakarta.ws.rs.Path("/{policyId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get a policy by id")
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
public Response getPolicy(@PathParam("policyId") String policyId) {
return policyStore
.get(policyId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
.map(policy -> Response.ok(policy).build())
.orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build());
}
@DeleteMapping("/{policyId}")
@DELETE
@jakarta.ws.rs.Path("/{policyId}")
@Operation(summary = "Delete a policy by id")
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
public Response deletePolicy(@PathParam("policyId") String policyId) {
return policyStore.delete(policyId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
? Response.noContent().build()
: Response.status(Response.Status.NOT_FOUND).build();
}
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@POST
@jakarta.ws.rs.Path("/{policyId}/run")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Run a stored policy",
description =
@@ -221,25 +244,29 @@ public class PolicyController {
+ " under 'fileInput', supporting files under their asset-key fields)."
+ " 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, MultipartHttpServletRequest request) throws IOException {
public Response runStoredPolicy(
@PathParam("policyId") String policyId, MultipartFormDataInput request)
throws IOException {
Policy policy =
policyStore
.get(policyId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
new WebApplicationException(
"No policy: " + policyId,
Response.Status.NOT_FOUND));
PolicyInputs inputs = collectInputs(request);
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
return Response.status(Response.Status.ACCEPTED)
.entity(new JobResponse<>(true, runId, null))
.build();
}
private Policy parsePolicy(String json) {
try {
return objectMapper.readValue(json, Policy.class);
} catch (JacksonException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON");
throw new WebApplicationException("Invalid policy JSON", Response.Status.BAD_REQUEST);
}
}
@@ -248,26 +275,43 @@ public class PolicyController {
try {
definition = objectMapper.readValue(json, PipelineDefinition.class);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON");
throw new WebApplicationException(
"Invalid pipeline definition JSON", 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);
}
return definition;
}
/**
* Extract a single text form field from the multipart request, mirroring Spring's
* {@code @RequestParam} behaviour (missing field -> 400).
*/
private static String formValue(MultipartFormDataInput request, String field) {
Collection<FormValue> values = request.getValues().get(field);
if (values != null) {
for (FormValue value : values) {
if (!value.isFileItem()) {
return value.getValue();
}
}
}
throw new WebApplicationException(
"Missing required field: " + field, Response.Status.BAD_REQUEST);
}
/**
* Split the multipart file parts into the primary document stream ("fileInput") and the named
* supporting-file store: every other file field becomes an asset keyed by its field name, which
* a step references from {@code fileParameters}.
*/
private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
MultiValueMap<String, MultipartFile> fileMap = request.getMultiFileMap();
List<Resource> primary = toResources(fileMap.get("fileInput"));
private PolicyInputs collectInputs(MultipartFormDataInput request) throws IOException {
Map<String, Collection<FormValue>> formData = request.getValues();
List<Resource> primary = toResources(formData.get("fileInput"));
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
for (Map.Entry<String, List<MultipartFile>> entry : fileMap.entrySet()) {
for (Map.Entry<String, Collection<FormValue>> entry : formData.entrySet()) {
if ("fileInput".equals(entry.getKey())) {
continue;
}
@@ -282,16 +326,24 @@ public class PolicyController {
/**
* A progress listener that forwards each step transition to the SSE stream as a "step" event.
*/
private PolicyProgressListener streamListener(SseEmitter emitter) {
private PolicyProgressListener streamListener(SseEventSink eventSink, Sse sse) {
return new PolicyProgressListener() {
@Override
public void onStepStart(int stepIndex, int stepCount, String operation) {
sendEvent(emitter, "step", stepEvent("started", stepIndex, stepCount, operation));
sendEvent(
eventSink,
sse,
"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(
eventSink,
sse,
"step",
stepEvent("completed", stepIndex, stepCount, operation));
}
};
}
@@ -316,30 +368,50 @@ public class PolicyController {
};
}
private void sendEvent(SseEmitter emitter, String name, Object data) {
private void sendEvent(SseEventSink eventSink, Sse sse, String name, Object data) {
if (eventSink.isClosed()) {
log.debug("Dropping policy SSE event '{}': sink already closed", name);
return;
}
try {
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
} catch (IOException | IllegalStateException e) {
// Client disconnected or the emitter already closed. The run continues and its results
OutboundSseEvent event =
sse.newEventBuilder()
.name(name)
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(data)
.build();
eventSink.send(event);
} catch (IllegalStateException e) {
// Client disconnected or the sink already closed. The run continues and its results
// remain downloadable via the job endpoints; nothing useful left to stream.
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
}
}
private List<Resource> toResources(List<MultipartFile> files) throws IOException {
private List<Resource> toResources(Collection<FormValue> files) throws IOException {
List<Resource> resources = new ArrayList<>();
if (files == null) {
return resources;
}
for (MultipartFile file : files) {
if (file == null || file.isEmpty()) {
for (FormValue file : files) {
if (file == null || !file.isFileItem()) {
continue;
}
long size;
try {
size = file.getFileItem().getFileSize();
} catch (IOException e) {
size = 0;
}
if (size == 0) {
continue;
}
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
file.getFileItem().write(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getFileName());
final Path tempPath = tempFile.getPath();
resources.add(
new FileSystemResource(tempFile.getFile()) {
new FileSystemResource(tempPath) {
@Override
public String getFilename() {
return originalName;
@@ -8,13 +8,12 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.io.Resource;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiTimeoutException;
@@ -48,7 +47,7 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
* instead of oversubscribing.
*/
@Slf4j
@Service
@ApplicationScoped
@RequiredArgsConstructor
public class PolicyEngine {
@@ -188,7 +187,7 @@ public class PolicyEngine {
}
}
private ResponseEntity<?> failRejectedRun(
private jakarta.ws.rs.core.Response failRejectedRun(
PolicyRun run, CompletableFuture<PolicyRun> completion, Throwable ex) {
// Only reached if the run never started (e.g. the queue was full). A run that started
// always resolves its own completion in runToCompletion.
@@ -2,7 +2,7 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.Resource;
import tools.jackson.databind.JsonNode;
@@ -3,22 +3,19 @@ package stirling.software.proprietary.policy.engine;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import jakarta.enterprise.context.ApplicationScoped;
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.io.Resource;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.ToolMetadataService;
@@ -47,7 +44,7 @@ import tools.jackson.databind.ObjectMapper;
* caller.
*/
@Slf4j
@Service
@ApplicationScoped
@RequiredArgsConstructor
public class PolicyExecutor {
@@ -162,9 +159,9 @@ public class PolicyExecutor {
PipelineStep step, List<Resource> files, Map<String, List<Resource>> supportingFiles)
throws IOException {
String endpointPath = step.operation();
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
Map<String, List<Object>> body = new LinkedHashMap<>();
for (Resource file : files) {
body.add("fileInput", file);
addToBody(body, "fileInput", file);
}
// Bind supporting files to their named tool fields (e.g. stampImage, overlayFiles). These
// come from the run's named asset store, not the document stream.
@@ -183,7 +180,7 @@ public class PolicyExecutor {
+ "' but no such file was provided");
}
for (Resource asset : assets) {
body.add(fieldName, asset);
addToBody(body, fieldName, asset);
}
}
for (Map.Entry<String, Object> entry : step.parameters().entrySet()) {
@@ -192,22 +189,25 @@ public class PolicyExecutor {
// Endpoints binding lists of structured objects (e.g. /security/redact's
// redactions, /general/edit-text's edits) parse a single JSON string field via
// a property editor. Pre-serialize the whole list so binding succeeds.
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
addToBody(body, entry.getKey(), objectMapper.writeValueAsString(list));
} else {
for (Object item : list) {
body.add(entry.getKey(), item);
addToBody(body, entry.getKey(), item);
}
}
} else {
body.add(entry.getKey(), entry.getValue());
addToBody(body, entry.getKey(), entry.getValue());
}
}
ResponseEntity<Resource> response = internalApiClient.post(endpointPath, body);
if (!HttpStatus.OK.equals(response.getStatusCode()) || response.getBody() == null) {
// The migrated InternalApiClient takes a Map<String, List<Object>> (replacing Spring's
// MultiValueMap) and returns a jakarta.ws.rs.core.Response.
Response response = internalApiClient.post(endpointPath, body);
if (response.getStatus() != Response.Status.OK.getStatusCode()
|| response.getEntity() == null) {
throw new IOException(
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
"Tool returned HTTP " + response.getStatus() + " for " + endpointPath);
}
Resource resource = response.getBody();
Resource resource = (Resource) response.getEntity();
// Filter operations return an empty body to signal the file was filtered out: drop it
// rather than forwarding a zero-byte document.
@@ -215,18 +215,17 @@ public class PolicyExecutor {
return new ToolResult(List.of(), null);
}
HttpHeaders headers = response.getHeaders();
MediaType contentType = headers.getContentType();
MediaType contentType = response.getMediaType();
// JSON-only response: the whole body is the structured report, no result file.
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
if (contentType != null && MediaType.APPLICATION_JSON_TYPE.isCompatible(contentType)) {
try (InputStream is = resource.getInputStream()) {
JsonNode report = objectMapper.readTree(is);
return new ToolResult(List.of(), report);
}
}
JsonNode report = parseReportHeader(headers, endpointPath);
JsonNode report = parseReportHeader(response, endpointPath);
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
return new ToolResult(ZipExtractionUtils.extractZip(resource, tempFileManager), report);
}
@@ -237,8 +236,8 @@ public class PolicyExecutor {
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
* or return null.
*/
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
private JsonNode parseReportHeader(Response response, String endpointPath) {
String raw = response.getHeaderString(AiToolResponseHeaders.TOOL_REPORT);
if (raw == null || raw.isBlank()) {
return null;
}
@@ -254,6 +253,11 @@ public class PolicyExecutor {
}
}
/** Append a value to the multi-valued form body (replaces Spring's MultiValueMap#add). */
private static void addToBody(Map<String, List<Object>> body, String name, Object value) {
body.computeIfAbsent(name, k -> new ArrayList<>()).add(value);
}
private static boolean containsStructuredElements(List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> || item instanceof List<?>) {
@@ -2,7 +2,7 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.Resource;
import lombok.Getter;
@@ -9,9 +9,8 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -31,7 +30,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
* cleanup, so eviction only frees this map's entry.
*/
@Slf4j
@Service
@ApplicationScoped
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -4,9 +4,10 @@ import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.All;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.policy.input.InputSource;
@@ -30,13 +31,18 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
* yields {@link ResolvedInput units of work}, each carrying its own completion hook.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ApplicationScoped
public class PolicyRunner {
private final PolicyEngine policyEngine;
private final List<InputSource> inputSources;
@jakarta.inject.Inject
public PolicyRunner(PolicyEngine policyEngine, @All List<InputSource> inputSources) {
this.policyEngine = policyEngine;
this.inputSources = inputSources;
}
/**
* Run a policy by pulling from every source it configures: each source yields zero or more
* units of work, and each unit becomes its own run so one failure does not affect the others. A
@@ -1,10 +1,8 @@
package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -22,13 +20,16 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
* <p>The trigger is optional (a {@code null} trigger is a manual-only policy and needs no
* validation); every configured source is validated.
*/
@Service
@RequiredArgsConstructor
@ApplicationScoped
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
private final List<InputSource> inputSources;
private final List<PolicyOutputSink> outputSinks;
// Spring injected a List<T> of all beans of each type; CDI collects all beans of a type
// via Instance<T>, which is iterable. Field injection is used (instead of constructor
// injection via Lombok @RequiredArgsConstructor) because Instance<T> is the CDI-native
// collection type and the fields cannot be final.
@Inject Instance<PolicyTrigger> triggers;
@Inject Instance<InputSource> inputSources;
@Inject Instance<PolicyOutputSink> outputSinks;
/**
* @throws IllegalArgumentException if any facet's type is unknown or its configuration is
@@ -9,13 +9,13 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -39,7 +39,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
* Readiness is checked first (via {@link FileReadinessChecker}) so files mid-write are skipped.
*/
@Slf4j
@Service
@ApplicationScoped
@RequiredArgsConstructor
public class FolderInputSource implements InputSource {
@@ -142,7 +142,7 @@ public class FolderInputSource implements InputSource {
private static Resource fileResource(Path path) {
String name = path.getFileName().toString();
return new FileSystemResource(path.toFile()) {
return new FileSystemResource(path) {
@Override
public String getFilename() {
return name;
@@ -3,7 +3,7 @@ package stirling.software.proprietary.policy.model;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.Resource;
/**
* The files a run operates on, split into two roles:
@@ -2,17 +2,20 @@ package stirling.software.proprietary.policy.output;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import jakarta.enterprise.context.ApplicationScoped;
import org.apache.commons.io.FilenameUtils;
// TODO: Migration required - the PolicyOutputSink interface (a collaborator) still declares
// List<Resource> using Spring's org.springframework.core.io.Resource; this import stays until that
// interface is migrated to stirling.software.common.model.io.Resource.
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -31,7 +34,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
* FileStorage} entry, so folder outputs are not downloadable via {@code /files/{id}}.
*/
@Slf4j
@Service
@ApplicationScoped
@RequiredArgsConstructor
public class FolderOutputSink implements PolicyOutputSink {
@@ -70,10 +73,11 @@ public class FolderOutputSink implements PolicyOutputSink {
Files.copy(is, target);
}
long size = Files.size(target);
String contentType =
MediaTypeFactory.getMediaType(name)
.orElse(MediaType.APPLICATION_OCTET_STREAM)
.toString();
// Spring's MediaTypeFactory.getMediaType(name) did extension-based content-type
// guessing; jakarta.ws.rs.core.MediaType has no equivalent factory, so use the JDK's
// URLConnection.guessContentTypeFromName and fall back to application/octet-stream.
String guessed = URLConnection.guessContentTypeFromName(name);
String contentType = guessed != null ? guessed : "application/octet-stream";
results.add(
ResultFile.builder()
.fileId(UUID.randomUUID().toString())
@@ -2,16 +2,15 @@ package stirling.software.proprietary.policy.output;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.io.Resource;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.policy.model.OutputSpec;
@@ -21,11 +20,12 @@ import stirling.software.proprietary.policy.model.OutputSpec;
* {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs
* whose results are returned to the caller.
*/
@Service
@ApplicationScoped
@RequiredArgsConstructor
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
private static final String APPLICATION_OCTET_STREAM = "application/octet-stream";
private final FileStorage fileStorage;
@@ -47,10 +47,8 @@ public class InlineOutputSink implements PolicyOutputSink {
Resource resource = outputs.get(i);
String name =
resource.getFilename() != null ? resource.getFilename() : "result-" + (i + 1);
String contentType =
MediaTypeFactory.getMediaType(name)
.orElse(MediaType.APPLICATION_OCTET_STREAM)
.toString();
String guessed = URLConnection.guessContentTypeFromName(name);
String contentType = guessed != null ? guessed : APPLICATION_OCTET_STREAM;
FileStorage.StoredFile stored;
try (InputStream is = resource.getInputStream()) {
stored = fileStorage.storeInputStream(is, name);
@@ -3,8 +3,7 @@ package stirling.software.proprietary.policy.output;
import java.io.IOException;
import java.util.List;
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.Resource;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.model.OutputSpec;
@@ -12,7 +11,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
* Delivers a finished run's output files to a destination, returning durable {@link ResultFile}
* descriptors (fileId + metadata) for the run record.
*
* <p>Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations
* <p>Implementations are CDI beans selected by {@link #supports(OutputSpec)}. New destinations
* (folder, S3) are added as new beans without changing the engine.
*/
public interface PolicyOutputSink {
@@ -4,7 +4,8 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
@@ -17,7 +18,7 @@ import tools.jackson.databind.ObjectMapper;
* (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the
* scalar columns are kept in sync for querying.
*/
@Service
@ApplicationScoped
@RequiredArgsConstructor
public class JpaPolicyStore implements PolicyStore {
@@ -25,6 +26,7 @@ public class JpaPolicyStore implements PolicyStore {
private final ObjectMapper objectMapper;
@Override
@Transactional
public Policy save(Policy policy) {
String id =
policy.id() == null || policy.id().isBlank()
@@ -48,18 +50,18 @@ public class JpaPolicyStore implements PolicyStore {
entity.setEnabled(stored.enabled());
entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type());
entity.setPolicyJson(objectMapper.writeValueAsString(stored));
repository.save(entity);
repository.persist(entity);
return stored;
}
@Override
public Optional<Policy> get(String id) {
return repository.findById(id).map(this::toPolicy);
return repository.findByIdOptional(id).map(this::toPolicy);
}
@Override
public List<Policy> all() {
return repository.findAll().stream().map(this::toPolicy).toList();
return repository.listAll().stream().map(this::toPolicy).toList();
}
@Override
@@ -70,12 +72,9 @@ public class JpaPolicyStore implements PolicyStore {
}
@Override
@Transactional
public boolean delete(String id) {
if (!repository.existsById(id)) {
return false;
}
repository.deleteById(id);
return true;
return repository.deleteById(id);
}
private Policy toPolicy(PolicyEntity entity) {
@@ -2,12 +2,15 @@ package stirling.software.proprietary.policy.store;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import jakarta.enterprise.context.ApplicationScoped;
@Repository
public interface PolicyRepository extends JpaRepository<PolicyEntity, String> {
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
@ApplicationScoped
public class PolicyRepository implements PanacheRepositoryBase<PolicyEntity, String> {
/** Enabled policies of a given trigger type, for background triggers to activate. */
List<PolicyEntity> findByTriggerTypeAndEnabledTrue(String triggerType);
public List<PolicyEntity> findByTriggerTypeAndEnabledTrue(String triggerType) {
return list("triggerType = ?1 and enabled = true", triggerType);
}
}
@@ -20,9 +20,11 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
import io.quarkus.arc.All;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@@ -49,8 +51,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
* node and rebuilds its registrations on restart from the {@link PolicyStore}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ApplicationScoped
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
@@ -60,6 +61,18 @@ public class FolderWatchTrigger implements PolicyTrigger {
private final List<InputSource> inputSources;
private final ApplicationProperties applicationProperties;
@Inject
public FolderWatchTrigger(
PolicyStore policyStore,
PolicyRunner policyRunner,
@All List<InputSource> inputSources,
ApplicationProperties applicationProperties) {
this.policyStore = policyStore;
this.policyRunner = policyRunner;
this.inputSources = inputSources;
this.applicationProperties = applicationProperties;
}
private final Map<Path, WatchKey> keysByDir = new ConcurrentHashMap<>();
private final Map<WatchKey, Path> dirByKey = new ConcurrentHashMap<>();
@@ -1,32 +1,32 @@
package stirling.software.proprietary.policy.trigger;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Service;
import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Starts and stops every {@link PolicyTrigger} with the application lifecycle. Background triggers
* (schedule, and future folder/S3) begin watching on {@link #start()} and release resources on
* {@link #stop()}; request-driven triggers (manual) are no-ops.
* (schedule, and future folder/S3) begin watching on startup and release resources on shutdown;
* request-driven triggers (manual) are no-ops.
*
* <p>This is the single activation point for triggers - a new background trigger only has to be a
* {@link PolicyTrigger} bean.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyTriggerManager implements SmartLifecycle {
@ApplicationScoped
public class PolicyTriggerManager {
private final List<PolicyTrigger> triggers;
@Inject Instance<PolicyTrigger> triggers;
private volatile boolean running;
@Override
public void start() {
public void start(@Observes StartupEvent event) {
for (PolicyTrigger trigger : triggers) {
try {
trigger.start();
@@ -37,8 +37,7 @@ public class PolicyTriggerManager implements SmartLifecycle {
running = true;
}
@Override
public void stop() {
public void stop(@Observes ShutdownEvent event) {
for (PolicyTrigger trigger : triggers) {
try {
trigger.stop();
@@ -49,7 +48,6 @@ public class PolicyTriggerManager implements SmartLifecycle {
running = false;
}
@Override
public boolean isRunning() {
return running;
}
@@ -10,7 +10,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -36,7 +36,7 @@ import tools.jackson.databind.ObjectMapper;
* on restart; cluster-wide coordination (leader election) is a follow-up.
*/
@Slf4j
@Service
@ApplicationScoped
@RequiredArgsConstructor
public class ScheduleTrigger implements PolicyTrigger {
@@ -4,259 +4,399 @@ import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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 org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Parameters;
import io.quarkus.panache.common.Sort;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
@Repository
public interface PersistentAuditEventRepository extends JpaRepository<PersistentAuditEvent, Long> {
/**
* Quarkus Panache repository for {@link PersistentAuditEvent}.
*
* <p>Migrated from a Spring Data {@code JpaRepository}. The original {@code @Query} JPQL strings are
* preserved verbatim and executed through Panache's {@link #find(String, Object...)} /
* {@link #find(String, io.quarkus.panache.common.Sort, java.util.Map)} APIs.
*
* <p>TODO: Migration required - the previous Spring Data signatures returned
* {@code org.springframework.data.domain.Page<T>} and accepted {@code
* org.springframework.data.domain.Pageable}. Those Spring types are gone in Quarkus; the paged
* finders below now return a Panache {@link PanacheQuery} and accept an
* {@code io.quarkus.panache.common.Page}. Collaborators that still consume the old Spring API
* (AuditRestController, AuditCleanupService, CustomAuditEventRepository) must be updated:
* <ul>
* <li>{@code page.getContent()} -> {@code query.page(page).list()}
* <li>{@code page.getTotalElements()} -> {@code query.count()}
* <li>{@code page.getTotalPages()} -> {@code query.pageCount()}
* <li>{@code page.getNumber()}/{@code getSize()} -> read from the requested
* {@code io.quarkus.panache.common.Page}
* <li>build the {@code io.quarkus.panache.common.Page} from the request's page index + size
* </ul>
*/
@ApplicationScoped
public class PersistentAuditEventRepository
implements PanacheRepositoryBase<PersistentAuditEvent, Long> {
// Basic queries
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%'))")
Page<PersistentAuditEvent> findByPrincipal(
@Param("principal") String principal, Pageable pageable);
// ---------------------------------------------------------------------
// Basic paged queries
// TODO: Migration required - callers must adapt to the PanacheQuery return type (see class doc).
// ---------------------------------------------------------------------
Page<PersistentAuditEvent> findByType(String type, Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByPrincipal(String principal) {
return find(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%'))",
Parameters.with("principal", principal));
}
Page<PersistentAuditEvent> findByTimestampBetween(
Instant startDate, Instant endDate, Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByType(String type) {
return find("type", type);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.type = :type")
Page<PersistentAuditEvent> findByPrincipalAndType(
@Param("principal") String principal, @Param("type") String type, Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByTimestampBetween(
Instant startDate, Instant endDate) {
return find(
"timestamp BETWEEN ?1 AND ?2", startDate, endDate);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByPrincipalAndTimestampBetween(
@Param("principal") String principal,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByPrincipalAndType(String principal, String type) {
return find(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.type = :type",
Parameters.with("principal", principal).and("type", type));
}
Page<PersistentAuditEvent> findByTypeAndTimestampBetween(
String type, Instant startDate, Instant endDate, Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByPrincipalAndTimestampBetween(
String principal, Instant startDate, Instant endDate) {
return find(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate",
Parameters.with("principal", principal)
.and("startDate", startDate)
.and("endDate", endDate));
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND"
+ " :endDate")
Page<PersistentAuditEvent> findByPrincipalAndTypeAndTimestampBetween(
@Param("principal") String principal,
@Param("type") String type,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByTypeAndTimestampBetween(
String type, Instant startDate, Instant endDate) {
return find(
"type = ?1 AND timestamp BETWEEN ?2 AND ?3", type, startDate, endDate);
}
public PanacheQuery<PersistentAuditEvent> findByPrincipalAndTypeAndTimestampBetween(
String principal, String type, Instant startDate, Instant endDate) {
return find(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate"
+ " AND :endDate",
Parameters.with("principal", principal)
.and("type", type)
.and("startDate", startDate)
.and("endDate", endDate));
}
// ---------------------------------------------------------------------
// Non-paged versions for export
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%'))")
List<PersistentAuditEvent> findAllByPrincipalForExport(@Param("principal") String principal);
// ---------------------------------------------------------------------
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type")
List<PersistentAuditEvent> findByTypeForExport(@Param("type") String type);
public List<PersistentAuditEvent> findAllByPrincipalForExport(String principal) {
return find(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE"
+ " UPPER(CONCAT('%', :principal, '%'))",
Parameters.with("principal", principal))
.list();
}
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp > :startDate")
List<PersistentAuditEvent> findByTypeAndTimestampAfterForExport(
@Param("type") String type, @Param("startDate") Instant startDate);
public List<PersistentAuditEvent> findByTypeForExport(String type) {
return list("type", type);
}
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findAllByTimestampBetweenForExport(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
public List<PersistentAuditEvent> findByTypeAndTimestampAfterForExport(
String type, Instant startDate) {
return list("type = ?1 AND timestamp > ?2", type, startDate);
}
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp > :startDate")
List<PersistentAuditEvent> findByTimestampAfter(@Param("startDate") Instant startDate);
public List<PersistentAuditEvent> findAllByTimestampBetweenForExport(
Instant startDate, Instant endDate) {
return list("timestamp BETWEEN ?1 AND ?2", startDate, endDate);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.type = :type")
List<PersistentAuditEvent> findAllByPrincipalAndTypeForExport(
@Param("principal") String principal, @Param("type") String type);
public List<PersistentAuditEvent> findByTimestampAfter(Instant startDate) {
return list("timestamp > ?1", startDate);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findAllByPrincipalAndTimestampBetweenForExport(
@Param("principal") String principal,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
public List<PersistentAuditEvent> findAllByPrincipalAndTypeForExport(
String principal, String type) {
return find(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE"
+ " UPPER(CONCAT('%', :principal, '%')) AND e.type = :type",
Parameters.with("principal", principal).and("type", type))
.list();
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN"
+ " :startDate AND :endDate")
List<PersistentAuditEvent> findAllByTypeAndTimestampBetweenForExport(
@Param("type") String type,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
public List<PersistentAuditEvent> findAllByPrincipalAndTimestampBetweenForExport(
String principal, Instant startDate, Instant endDate) {
return find(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE"
+ " UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN"
+ " :startDate AND :endDate",
Parameters.with("principal", principal)
.and("startDate", startDate)
.and("endDate", endDate))
.list();
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND"
+ " :endDate")
List<PersistentAuditEvent> findAllByPrincipalAndTypeAndTimestampBetweenForExport(
@Param("principal") String principal,
@Param("type") String type,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
public List<PersistentAuditEvent> findAllByTypeAndTimestampBetweenForExport(
String type, Instant startDate, Instant endDate) {
return list(
"type = ?1 AND timestamp BETWEEN ?2 AND ?3", type, startDate, endDate);
}
public List<PersistentAuditEvent> findAllByPrincipalAndTypeAndTimestampBetweenForExport(
String principal, String type, Instant startDate, Instant endDate) {
return find(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE"
+ " UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND"
+ " e.timestamp BETWEEN :startDate AND :endDate",
Parameters.with("principal", principal)
.and("type", type)
.and("startDate", startDate)
.and("endDate", endDate))
.list();
}
// ---------------------------------------------------------------------
// Cleanup queries
@Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1")
@Modifying
// ---------------------------------------------------------------------
@Transactional
int deleteByTimestampBefore(Instant cutoffDate);
public int deleteByTimestampBefore(Instant cutoffDate) {
return (int) delete("timestamp < ?1", cutoffDate);
}
// Find IDs for batch deletion - using JPQL with setMaxResults instead of native query
@Query("SELECT e.id FROM PersistentAuditEvent e WHERE e.timestamp < ?1 ORDER BY e.id")
List<Long> findIdsForBatchDeletion(Instant cutoffDate, Pageable pageable);
/**
* Find IDs for batch deletion - using JPQL with paging instead of a native query.
*
* <p>TODO: Migration required - originally accepted a Spring {@code Pageable}; callers must pass
* an {@code io.quarkus.panache.common.Page} instead (see class doc).
*/
public List<Long> findIdsForBatchDeletion(Instant cutoffDate, io.quarkus.panache.common.Page page) {
return getEntityManager()
.createQuery(
"SELECT e.id FROM PersistentAuditEvent e WHERE e.timestamp < :cutoffDate"
+ " ORDER BY e.id",
Long.class)
.setParameter("cutoffDate", cutoffDate)
.setFirstResult(page.index * page.size)
.setMaxResults(page.size)
.getResultList();
}
// ---------------------------------------------------------------------
// Stats queries
@Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type")
List<Object[]> countByType();
// ---------------------------------------------------------------------
@Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal")
List<Object[]> countByPrincipal();
public List<Object[]> countByType() {
return getEntityManager()
.createQuery(
"SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type",
Object[].class)
.getResultList();
}
@Query(
"SELECT e.type, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN"
+ " :startDate AND :endDate GROUP BY e.type")
List<Object[]> countByTypeBetween(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
public List<Object[]> countByPrincipal() {
return getEntityManager()
.createQuery(
"SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY"
+ " e.principal",
Object[].class)
.getResultList();
}
@Query(
"SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN"
+ " :startDate AND :endDate GROUP BY e.principal")
List<Object[]> countByPrincipalBetween(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
public List<Object[]> countByTypeBetween(Instant startDate, Instant endDate) {
return getEntityManager()
.createQuery(
"SELECT e.type, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp"
+ " BETWEEN :startDate AND :endDate GROUP BY e.type",
Object[].class)
.setParameter("startDate", startDate)
.setParameter("endDate", endDate)
.getResultList();
}
public List<Object[]> countByPrincipalBetween(Instant startDate, Instant endDate) {
return getEntityManager()
.createQuery(
"SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp"
+ " BETWEEN :startDate AND :endDate GROUP BY e.principal",
Object[].class)
.setParameter("startDate", startDate)
.setParameter("endDate", endDate)
.getResultList();
}
// Portable time-bucketing using YEAR/MONTH/DAY functions (works across most dialects)
@Query(
"SELECT YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp), COUNT(e) "
+ "FROM PersistentAuditEvent e "
+ "WHERE e.timestamp BETWEEN :startDate AND :endDate "
+ "GROUP BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp) "
+ "ORDER BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp)")
List<Object[]> histogramByDayBetween(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
public List<Object[]> histogramByDayBetween(Instant startDate, Instant endDate) {
return getEntityManager()
.createQuery(
"SELECT YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp), COUNT(e) "
+ "FROM PersistentAuditEvent e "
+ "WHERE e.timestamp BETWEEN :startDate AND :endDate "
+ "GROUP BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp) "
+ "ORDER BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp)",
Object[].class)
.setParameter("startDate", startDate)
.setParameter("endDate", endDate)
.getResultList();
}
@Query(
"SELECT HOUR(e.timestamp), COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp"
+ " BETWEEN :startDate AND :endDate GROUP BY HOUR(e.timestamp) ORDER BY"
+ " HOUR(e.timestamp)")
List<Object[]> histogramByHourBetween(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
public List<Object[]> histogramByHourBetween(Instant startDate, Instant endDate) {
return getEntityManager()
.createQuery(
"SELECT HOUR(e.timestamp), COUNT(e) FROM PersistentAuditEvent e WHERE"
+ " e.timestamp BETWEEN :startDate AND :endDate GROUP BY"
+ " HOUR(e.timestamp) ORDER BY HOUR(e.timestamp)",
Object[].class)
.setParameter("startDate", startDate)
.setParameter("endDate", endDate)
.getResultList();
}
// ---------------------------------------------------------------------
// Get distinct event types for filtering
@Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type")
List<String> findDistinctEventTypes();
// ---------------------------------------------------------------------
@Query("SELECT DISTINCT e.principal FROM PersistentAuditEvent e ORDER BY e.principal")
List<String> findDistinctPrincipals();
public List<String> findDistinctEventTypes() {
return getEntityManager()
.createQuery(
"SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type",
String.class)
.getResultList();
}
@Query(
"SELECT DISTINCT e.principal FROM PersistentAuditEvent e WHERE e.type = :type ORDER BY"
+ " e.principal")
List<String> findDistinctPrincipalsByType(@Param("type") String type);
public List<String> findDistinctPrincipals() {
return getEntityManager()
.createQuery(
"SELECT DISTINCT e.principal FROM PersistentAuditEvent e ORDER BY"
+ " e.principal",
String.class)
.getResultList();
}
public List<String> findDistinctPrincipalsByType(String type) {
return getEntityManager()
.createQuery(
"SELECT DISTINCT e.principal FROM PersistentAuditEvent e WHERE e.type ="
+ " :type ORDER BY e.principal",
String.class)
.setParameter("type", type)
.getResultList();
}
// ---------------------------------------------------------------------
// Top/Latest helpers & existence checks
Optional<PersistentAuditEvent> findTopByOrderByTimestampDesc();
// ---------------------------------------------------------------------
Optional<PersistentAuditEvent> findTopByPrincipalOrderByTimestampDesc(String principal);
public Optional<PersistentAuditEvent> findTopByOrderByTimestampDesc() {
return find("", Sort.by("timestamp").descending()).firstResultOptional();
}
Optional<PersistentAuditEvent> findTopByTypeOrderByTimestampDesc(String type);
public Optional<PersistentAuditEvent> findTopByPrincipalOrderByTimestampDesc(String principal) {
return find("principal", Sort.by("timestamp").descending(), principal).firstResultOptional();
}
public Optional<PersistentAuditEvent> findTopByTypeOrderByTimestampDesc(String type) {
return find("type", Sort.by("timestamp").descending(), type).firstResultOptional();
}
// ---------------------------------------------------------------------
// Multi-value queries for filtering by multiple types and/or principals
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types")
Page<PersistentAuditEvent> findByTypeIn(@Param("types") List<String> types, Pageable pageable);
// TODO: Migration required - callers must adapt to the PanacheQuery return type (see class doc).
// ---------------------------------------------------------------------
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals")
Page<PersistentAuditEvent> findByPrincipalIn(
@Param("principals") List<String> principals, Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByTypeIn(List<String> types) {
return find("type IN ?1", types);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByTypeInAndTimestampBetween(
@Param("types") List<String> types,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByPrincipalIn(List<String> principals) {
return find("principal IN ?1", principals);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByPrincipalInAndTimestampBetween(
@Param("principals") List<String> principals,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByTypeInAndTimestampBetween(
List<String> types, Instant startDate, Instant endDate) {
return find(
"type IN ?1 AND timestamp BETWEEN ?2 AND ?3", types, startDate, endDate);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals")
Page<PersistentAuditEvent> findByTypeInAndPrincipalIn(
@Param("types") List<String> types,
@Param("principals") List<String> principals,
Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByPrincipalInAndTimestampBetween(
List<String> principals, Instant startDate, Instant endDate) {
return find(
"principal IN ?1 AND timestamp BETWEEN ?2 AND ?3", principals, startDate, endDate);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetween(
@Param("types") List<String> types,
@Param("principals") List<String> principals,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable);
public PanacheQuery<PersistentAuditEvent> findByTypeInAndPrincipalIn(
List<String> types, List<String> principals) {
return find("type IN ?1 AND principal IN ?2", types, principals);
}
public PanacheQuery<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetween(
List<String> types, List<String> principals, Instant startDate, Instant endDate) {
return find(
"type IN ?1 AND principal IN ?2 AND timestamp BETWEEN ?3 AND ?4",
types,
principals,
startDate,
endDate);
}
// ---------------------------------------------------------------------
// Export versions (non-paged)
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types")
List<PersistentAuditEvent> findByTypeInForExport(@Param("types") List<String> types);
// ---------------------------------------------------------------------
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals")
List<PersistentAuditEvent> findByPrincipalInForExport(
@Param("principals") List<String> principals);
public List<PersistentAuditEvent> findByTypeInForExport(List<String> types) {
return list("type IN ?1", types);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findByTypeInAndTimestampBetweenForExport(
@Param("types") List<String> types,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
public List<PersistentAuditEvent> findByPrincipalInForExport(List<String> principals) {
return list("principal IN ?1", principals);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findByPrincipalInAndTimestampBetweenForExport(
@Param("principals") List<String> principals,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
public List<PersistentAuditEvent> findByTypeInAndTimestampBetweenForExport(
List<String> types, Instant startDate, Instant endDate) {
return list(
"type IN ?1 AND timestamp BETWEEN ?2 AND ?3", types, startDate, endDate);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals")
List<PersistentAuditEvent> findByTypeInAndPrincipalInForExport(
@Param("types") List<String> types, @Param("principals") List<String> principals);
public List<PersistentAuditEvent> findByPrincipalInAndTimestampBetweenForExport(
List<String> principals, Instant startDate, Instant endDate) {
return list(
"principal IN ?1 AND timestamp BETWEEN ?2 AND ?3", principals, startDate, endDate);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetweenForExport(
@Param("types") List<String> types,
@Param("principals") List<String> principals,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
public List<PersistentAuditEvent> findByTypeInAndPrincipalInForExport(
List<String> types, List<String> principals) {
return list("type IN ?1 AND principal IN ?2", types, principals);
}
public List<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetweenForExport(
List<String> types, List<String> principals, Instant startDate, Instant endDate) {
return list(
"type IN ?1 AND principal IN ?2 AND timestamp BETWEEN ?3 AND ?4",
types,
principals,
startDate,
endDate);
}
// Query events excluding a specific type (used for analytics where we want to exclude UI_DATA)
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType")
List<PersistentAuditEvent> findAllExceptTypeForExport(@Param("excludeType") String excludeType);
public List<PersistentAuditEvent> findAllExceptTypeForExport(String excludeType) {
return list("type != ?1", excludeType);
}
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate")
List<PersistentAuditEvent> findAllExceptTypeAndTimestampAfterForExport(
@Param("excludeType") String excludeType, @Param("startDate") Instant startDate);
public List<PersistentAuditEvent> findAllExceptTypeAndTimestampAfterForExport(
String excludeType, Instant startDate) {
return list("type != ?1 AND timestamp > ?2", excludeType, startDate);
}
}
@@ -3,14 +3,8 @@ package stirling.software.proprietary.security;
import java.io.IOException;
import java.util.Optional;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -24,38 +18,60 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
// TODO: Migration required - this class extended Spring Security's
// SimpleUrlAuthenticationFailureHandler and was wired into the form-login
// SecurityFilterChain. Quarkus has no direct equivalent for an
// AuthenticationFailureHandler. The login-failure flow (lockout, bad
// credentials, oauth2 errors, disabled users) must be re-hosted on a Quarkus
// authentication mechanism - typically a custom form-auth (quarkus.http.auth.*)
// or quarkus-oidc - with the redirect decisions implemented in a
// jakarta.ws.rs.container.ContainerRequestFilter / custom HttpAuthenticationMechanism
// that inspects the AuthenticationFailedException. The decision logic below is
// preserved verbatim so it can be reused; the Spring AuthenticationException
// type hierarchy (BadCredentialsException, DisabledException, LockedException,
// UsernameNotFoundException, InternalAuthenticationServiceException) and
// getRedirectStrategy()/sendRedirect() must be replaced with the Quarkus
// equivalents. The exception parameter is currently typed as a generic
// java.lang.Throwable until the Quarkus mechanism's failure type is decided.
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@ApplicationScoped
public class CustomAuthenticationFailureHandler {
private LoginAttemptService loginAttemptService;
private UserService userService;
@Inject
public CustomAuthenticationFailureHandler(
final LoginAttemptService loginAttemptService, UserService userService) {
this.loginAttemptService = loginAttemptService;
this.userService = userService;
}
@Override
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception)
HttpServletRequest request, HttpServletResponse response, Throwable exception)
throws IOException, ServletException {
if (exception instanceof DisabledException) {
// TODO: Migration required - replace Spring exception type checks below
// (DisabledException, LockedException, BadCredentialsException,
// UsernameNotFoundException, InternalAuthenticationServiceException) with
// the Quarkus authentication-failure type(s), and replace each
// getRedirectStrategy().sendRedirect(request, response, "...") call with
// a Quarkus redirect (e.g. response.sendRedirect(...) or building a 302
// jakarta.ws.rs.core.Response from the auth mechanism).
if (isDisabled(exception)) {
log.error("User is deactivated: ", exception);
getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true");
// TODO: Migration required - sendRedirect("/logout?userIsDisabled=true")
return;
}
String ip = request.getRemoteAddr();
log.error("Failed login attempt from IP: {}", ip);
if (exception instanceof LockedException) {
getRedirectStrategy().sendRedirect(request, response, "/login?error=locked");
if (isLocked(exception)) {
// TODO: Migration required - sendRedirect("/login?error=locked")
return;
}
@@ -68,24 +84,47 @@ public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationF
username,
loginAttemptService.getRemainingAttempts(username));
loginAttemptService.loginFailed(username);
if (loginAttemptService.isBlocked(username) || exception instanceof LockedException) {
getRedirectStrategy().sendRedirect(request, response, "/login?error=locked");
if (loginAttemptService.isBlocked(username) || isLocked(exception)) {
// TODO: Migration required - sendRedirect("/login?error=locked")
return;
}
}
if (exception instanceof BadCredentialsException
|| exception instanceof UsernameNotFoundException) {
getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials");
if (isBadCredentials(exception) || isUsernameNotFound(exception)) {
// TODO: Migration required - sendRedirect("/login?error=badCredentials")
return;
}
if (exception instanceof InternalAuthenticationServiceException
if (isInternalAuthError(exception)
|| "Password must not be null".equalsIgnoreCase(exception.getMessage())) {
getRedirectStrategy()
.sendRedirect(request, response, "/login?error=oauth2AuthenticationError");
// TODO: Migration required - sendRedirect("/login?error=oauth2AuthenticationError")
return;
}
super.onAuthenticationFailure(request, response, exception);
// TODO: Migration required - default failure handling previously delegated
// to SimpleUrlAuthenticationFailureHandler.onAuthenticationFailure (redirect
// to the configured failure URL).
}
// TODO: Migration required - these predicates stand in for Spring Security's
// exception type hierarchy and must be rewired to the Quarkus
// authentication-failure type(s) once the auth mechanism is chosen.
private boolean isDisabled(Throwable exception) {
return false;
}
private boolean isLocked(Throwable exception) {
return false;
}
private boolean isBadCredentials(Throwable exception) {
return false;
}
private boolean isUsernameNotFound(Throwable exception) {
return false;
}
private boolean isInternalAuthError(Throwable exception) {
return false;
}
private boolean isDemoUser(Optional<User> user) {
@@ -3,10 +3,8 @@ package stirling.software.proprietary.security;
import java.io.IOException;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -23,14 +21,31 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
// TODO: Migration required - this class previously extended Spring Security's
// SavedRequestAwareAuthenticationSuccessHandler, which is part of the Spring Security
// form-login filter chain (RedirectStrategy + SavedRequest from the HttpSession). Quarkus
// has no direct equivalent: post-login redirects are handled by quarkus-oidc / form-auth
// (quarkus.http.auth.form.landing-page, .location-cookie) or by a custom
// jakarta.servlet.Filter / ContainerRequestFilter / HttpAuthenticationMechanism. The
// business logic below (disabled-user check, login-attempt tracking, JWT minting, and the
// static-resource redirect decision) is preserved and should be invoked from whatever
// Quarkus authentication-success hook replaces this handler. The Spring
// SavedRequestAwareAuthenticationSuccessHandler super.onAuthenticationSuccess(...) call and
// getRedirectStrategy() have been replaced with plain HttpServletResponse#sendRedirect.
//
// TODO: Migration required - the @Audited(USER_LOGIN) interception relied on the Spring AOP
// AuditAspect wrapping this Spring-managed handler bean. Ensure the migrated AuditAspect
// (CDI interceptor) still binds to this method, or audit the login event from the new
// authentication-success hook.
@Slf4j
public class CustomAuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
@ApplicationScoped
public class CustomAuthenticationSuccessHandler {
private final LoginAttemptService loginAttemptService;
private final UserService userService;
private final JwtServiceInterface jwtService;
@Inject
public CustomAuthenticationSuccessHandler(
LoginAttemptService loginAttemptService,
UserService userService,
@@ -40,43 +55,65 @@ public class CustomAuthenticationSuccessHandler
this.jwtService = jwtService;
}
@Override
// TODO: Migration required - signature changed from Spring's
// onAuthenticationSuccess(HttpServletRequest, HttpServletResponse,
// org.springframework.security.core.Authentication). The Spring Authentication parameter
// has been dropped here; JwtServiceInterface#generateToken(Authentication, ...) still
// requires it (JwtServiceInterface is a separate file that must be migrated to accept a
// Quarkus SecurityIdentity / principal). For now the username is read from the request
// parameter as before; wire the authenticated identity in once JwtServiceInterface is
// migrated.
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String userName = request.getParameter("username");
if (userService.isUserDisabled(userName)) {
getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true");
response.sendRedirect("/logout?userIsDisabled=true");
return;
}
loginAttemptService.loginSucceeded(userName);
if (jwtService.isJwtEnabled()) {
// TODO: Migration required - JwtServiceInterface#generateToken expected a Spring
// Authentication. Pass the migrated Quarkus identity once JwtServiceInterface is
// ported; generating the token by username for now.
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.WEB));
userName, Map.of("authType", AuthenticationType.WEB));
log.debug("JWT generated for user: {}", userName);
getRedirectStrategy().sendRedirect(request, response, "/");
response.sendRedirect("/");
} else {
// Get the saved request
HttpSession session = request.getSession(false);
SavedRequest savedRequest =
// TODO: Migration required - "SPRING_SECURITY_SAVED_REQUEST" was populated by the
// Spring Security RequestCache. Without the Spring filter chain this attribute is
// never set, so this branch always falls through to the home-page redirect. The
// original-destination redirect must be reimplemented via the Quarkus form-auth
// location cookie or a custom request cache.
Object savedRequest =
(session != null)
? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST")
? session.getAttribute("SPRING_SECURITY_SAVED_REQUEST")
: null;
if (savedRequest != null
String savedRedirectUrl = extractSavedRedirectUrl(savedRequest);
if (savedRedirectUrl != null
&& !RequestUriUtils.isStaticResource(
request.getContextPath(), savedRequest.getRedirectUrl())) {
request.getContextPath(), savedRedirectUrl)) {
// Redirect to the original destination
super.onAuthenticationSuccess(request, response, authentication);
response.sendRedirect(savedRedirectUrl);
} else {
// No saved request or it's a static resource, redirect to home page
getRedirectStrategy().sendRedirect(request, response, "/");
response.sendRedirect("/");
}
}
}
// TODO: Migration required - placeholder for reading the redirect URL off whatever object
// the migrated request cache stores. The Spring SavedRequest#getRedirectUrl() is gone.
private String extractSavedRedirectUrl(Object savedRequest) {
return null;
}
}
@@ -7,18 +7,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import com.coveo.saml.SamlClient;
import com.coveo.saml.SamlException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -29,6 +22,7 @@ import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.io.Resource;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.UrlUtils;
@@ -36,13 +30,19 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.service.AiUserDataService;
// TODO: Migration required - this class was a Spring Security
// SimpleUrlLogoutSuccessHandler wired into the Spring Security logout filter chain.
// Quarkus has no LogoutSuccessHandler equivalent. The logout endpoint must be rehosted
// (e.g. a JAX-RS resource or jakarta.servlet endpoint) that invokes onLogoutSuccess(...)
// after the Quarkus security/session logout has run. Configure HTTP auth/logout policies
// via quarkus.http.auth.* and quarkus-oidc (for OAuth2/OIDC logout).
@Slf4j
@RequiredArgsConstructor
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
@ApplicationScoped
@RequiredArgsConstructor(onConstructor_ = @Inject)
public class CustomLogoutSuccessHandler {
public static final String LOGOUT_PATH = "/login?logout=true";
@@ -54,13 +54,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private final AiUserDataService aiUserDataService;
private static final AuthenticationTrustResolver TRUST_RESOLVER =
new AuthenticationTrustResolverImpl();
// TODO: Migration required - Spring's AuthenticationTrustResolver
// (used to filter out the anonymous principal) has no direct Quarkus equivalent.
// Under Quarkus, an unauthenticated request yields an anonymous SecurityIdentity
// (SecurityIdentity#isAnonymous()); use that check in resolveUsername(...) instead.
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
public void onLogoutSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
HttpServletRequest request, HttpServletResponse response, Object authentication)
throws IOException {
String username = resolveUsername(request, authentication);
@@ -70,75 +71,80 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
if (!response.isCommitted()) {
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
// Handle SAML2 logout redirection
getRedirect_saml2(request, response, samlAuthentication);
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
// Handle Username/Password logout
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
} else {
// Handle unknown authentication types
log.error(
"Authentication class unknown: {}",
authentication.getClass().getSimpleName());
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
// TODO: Migration required - the original code branched on the Spring
// Authentication implementation type to choose a logout redirect:
// Saml2Authentication -> getRedirect_saml2(...)
// OAuth2AuthenticationToken -> getRedirect_oauth2(...)
// UsernamePasswordAuthentication -> redirect to LOGOUT_PATH
// unknown -> log + redirect to LOGOUT_PATH
// Under Quarkus the authentication mechanism is identified differently
// (SecurityIdentity attributes / quarkus-oidc vs form auth, or the IdP
// recorded at login). Re-wire this dispatch to invoke getRedirect_saml2 /
// getRedirect_oauth2 once the Quarkus identity model is in place. Until
// then we fall through to the default login-page redirect to preserve
// safe behavior (a single redirect, never IdP logout with a null subject).
response.sendRedirect(LOGOUT_PATH);
} else {
if (jwtService != null) {
String token = jwtService.extractToken(request);
if (token != null && !token.isBlank()) {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
response.sendRedirect(LOGOUT_PATH);
return;
}
}
// Redirect to login page after logout
String path = checkForErrors(request);
getRedirectStrategy().sendRedirect(request, response, path);
response.sendRedirect(path);
}
}
}
/**
* Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through
* to whatever Spring handed us only when there's no cookie. Spring's anonymous principal is
* filtered out via {@link AuthenticationTrustResolver} so we don't purge under that
* pseudo-user.
* to whatever the authentication handed us only when there's no cookie. The anonymous principal
* is filtered out so we don't purge under that pseudo-user.
*/
private String resolveUsername(HttpServletRequest request, Authentication authentication) {
private String resolveUsername(HttpServletRequest request, Object authentication) {
if (jwtService != null) {
String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request);
if (fromCookie != null) {
return fromCookie;
}
}
if (authentication == null || TRUST_RESOLVER.isAnonymous(authentication)) {
// TODO: Migration required - replace the Spring AuthenticationTrustResolver
// anonymous check and Authentication#getName() with SecurityIdentity:
// if (identity == null || identity.isAnonymous()) return null;
// String name = identity.getPrincipal().getName();
if (authentication == null) {
return null;
}
String name = authentication.getName();
return (name != null && !name.isBlank()) ? name : null;
return null;
}
// Redirect for SAML2 authentication logout
// TODO: Migration required - parameter was Spring Saml2Authentication; the SAML2
// principal (CustomSaml2AuthenticatedPrincipal) must be recovered from the Quarkus
// identity once the SAML SP is rehosted on OpenSAML 5 (see SAML2 migration plan).
private void getRedirect_saml2(
HttpServletRequest request,
HttpServletResponse response,
Saml2Authentication samlAuthentication)
HttpServletRequest request, HttpServletResponse response, Object samlAuthentication)
throws IOException {
SAML2 samlConf = securityProperties.getSaml2();
String registrationId = samlConf.getRegistrationId();
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
String nameIdValue = principal.name();
// TODO: Migration required - extract the SAML NameID from the Quarkus identity.
// Original:
// CustomSaml2AuthenticatedPrincipal principal =
// (CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
// String nameIdValue = principal.name();
String nameIdValue = null;
try {
// Read certificate from the resource
Resource certificateResource = samlConf.getSpCert();
// TODO: Migration required - CertificateUtils still declares
// org.springframework.core.io.Resource parameters; once it is migrated to
// stirling.software.common.model.io.Resource these calls compile directly.
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
List<X509Certificate> certificates = new ArrayList<>();
@@ -166,22 +172,26 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
samlConf.getProvider(),
nameIdValue,
e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
response.sendRedirect(LOGOUT_PATH);
}
}
// Redirect for OAuth2 authentication logout
// TODO: Migration required - parameter was Spring OAuth2AuthenticationToken; under
// quarkus-oidc the authorized client registration id must be obtained from the OIDC
// configuration / SecurityIdentity rather than the token.
private void getRedirect_oauth2(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthenticationToken oAuthToken)
HttpServletRequest request, HttpServletResponse response, Object oAuthToken)
throws IOException {
String registrationId;
OAUTH2 oauth = securityProperties.getOauth2();
String path = checkForErrors(request);
String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path;
registrationId = oAuthToken.getAuthorizedClientRegistrationId();
// TODO: Migration required - original:
// registrationId = oAuthToken.getAuthorizedClientRegistrationId();
// Resolve the OIDC provider id from quarkus-oidc config / SecurityIdentity instead.
registrationId = "";
// Redirect based on OAuth2 provider
switch (registrationId.toLowerCase(Locale.ROOT)) {
@@ -5,10 +5,10 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -25,12 +25,12 @@ import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@Slf4j
@Component
@ApplicationScoped
@RequiredArgsConstructor
public class InitialSecuritySetup {
@Value("${v2:false}")
private boolean v2Enabled = false;
@ConfigProperty(name = "v2", defaultValue = "false")
boolean v2Enabled;
private final UserService userService;
private final TeamService teamService;
@@ -2,20 +2,24 @@ package stirling.software.proprietary.security;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
// TODO: Migration required - this was a Spring Security AuthenticationEntryPoint
// (org.springframework.security.web.AuthenticationEntryPoint). Quarkus has no direct
// AuthenticationEntryPoint SPI; unauthenticated-access handling is wired via
// quarkus.http.auth.* policies and an AuthenticationFailedException mapper / a
// jakarta.ws.rs.ext.ExceptionMapper<io.quarkus.security.UnauthorizedException> (or a
// ContainerRequestFilter). The response-shaping logic below is preserved as a plain
// helper bean; the caller that previously registered this entry point must invoke
// commence(...) from the Quarkus failure-handling path. The AuthenticationException
// parameter was replaced with a generic Exception to drop the Spring dependency.
@ApplicationScoped
public class JwtAuthenticationEntryPoint {
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
HttpServletRequest request, HttpServletResponse response, Exception authException)
throws IOException {
String contextPath = request.getContextPath();
String requestURI = request.getRequestURI();
@@ -30,7 +34,9 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
response.getWriter().write("{\"error\":\"" + message + "\"}");
} else {
// For non-API requests, use default behavior
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
authException != null ? authException.getMessage() : "Authentication required");
}
}
}
@@ -1,21 +1,28 @@
package stirling.software.proprietary.security;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import io.quarkus.arc.profile.UnlessBuildProfile;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
@Component
@Profile("!saas")
// TODO: Migration required - Spring @Profile("!saas") gated this scheduler so it never ran in the
// "saas" profile. @io.quarkus.arc.profile.UnlessBuildProfile("saas") reproduces this when "saas" is
// a Quarkus BUILD profile; if "saas" is only a runtime profile, this annotation has no effect and
// the body of resetRateLimit() must instead short-circuit on a runtime profile check
// (org.eclipse.microprofile.config Config "quarkus.profile" / ProfileManager.getActiveProfile()).
@ApplicationScoped
@UnlessBuildProfile("saas")
@RequiredArgsConstructor
public class RateLimitResetScheduler {
private final IPRateLimitingFilter rateLimitingFilter;
@Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}")
// Quarkus @Scheduled cron supports the "{property:default}" placeholder syntax (no '$').
@Scheduled(cron = "{security.rate-limit.reset-schedule:0 0 0 * * MON}")
public void resetRateLimit() {
rateLimitingFilter.resetRequestCounts();
}
@@ -1,30 +1,50 @@
package stirling.software.proprietary.security.config;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
@Aspect
@Component
/**
* MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} {@code @Component} with
* {@code @Around} advice matching {@code @annotation(EnterpriseEndpoint)} /
* {@code @within(EnterpriseEndpoint)}. Reworked into a CDI {@link Interceptor} bound by the
* {@code @EnterpriseEndpoint} annotation (pattern: common/aop/AutoJobAspect). {@code @Around} +
* {@code ProceedingJoinPoint} became {@code @AroundInvoke} + {@link InvocationContext};
* {@code joinPoint.proceed()} -> {@code ctx.proceed()}. The Spring
* {@code ResponseStatusException(HttpStatus.FORBIDDEN, ...)} became a JAX-RS
* {@link WebApplicationException} with {@link Response.Status#FORBIDDEN}. The
* {@code @Qualifier("runningEE")} ctor param became {@code @Inject @Named("runningEE")}.
*
* <p>TODO: Migration required - for this CDI interceptor to fire, the collaborator annotation
* {@code stirling.software.proprietary.security.config.EnterpriseEndpoint} must be made a CDI
* {@code @jakarta.interceptor.InterceptorBinding} (it is currently a plain runtime annotation), and
* the interceptor must be enabled (Quarkus enables {@code @Interceptor} beans automatically once the
* binding is an {@code @InterceptorBinding}; no beans.xml ordering change needed). It already
* targets METHOD and TYPE, matching the original {@code @annotation}/{@code @within} pointcut.
*/
@Interceptor
@EnterpriseEndpoint
@Priority(Interceptor.Priority.APPLICATION)
public class EnterpriseEndpointAspect {
private final boolean runningEE;
public EnterpriseEndpointAspect(@Qualifier("runningEE") boolean runningEE) {
@Inject
public EnterpriseEndpointAspect(@Named("runningEE") boolean runningEE) {
this.runningEE = runningEE;
}
@Around(
"@annotation(stirling.software.proprietary.security.config.EnterpriseEndpoint) || @within(stirling.software.proprietary.security.config.EnterpriseEndpoint)")
public Object checkEnterpriseAccess(ProceedingJoinPoint joinPoint) throws Throwable {
@AroundInvoke
public Object checkEnterpriseAccess(InvocationContext ctx) throws Exception {
if (!runningEE) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This endpoint requires an Enterprise license");
throw new WebApplicationException(
"This endpoint requires an Enterprise license", Response.Status.FORBIDDEN);
}
return joinPoint.proceed();
return ctx.proceed();
}
}
@@ -1,30 +1,47 @@
package stirling.software.proprietary.security.config;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
@Aspect
@Component
/**
* MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} with {@code @Around} advice on
* the {@code @PremiumEndpoint} pointcut ({@code @annotation || @within}). Reworked into a CDI
* {@link Interceptor} bound by the {@code @PremiumEndpoint} {@code @InterceptorBinding};
* {@code @Around}/{@code ProceedingJoinPoint} became {@code @AroundInvoke}/{@link InvocationContext}.
* The Spring {@code ResponseStatusException(HttpStatus.FORBIDDEN, ...)} became a JAX-RS
* {@link WebApplicationException} with {@link Response.Status#FORBIDDEN}.
*
* <p>TODO: Migration required - the {@code @PremiumEndpoint} annotation (collaborator file
* stirling.software.proprietary.security.config.PremiumEndpoint) must be annotated with
* {@code @jakarta.interceptor.InterceptorBinding} (and target METHOD + TYPE, retention RUNTIME) for
* this CDI interceptor to bind. Both method-level ({@code @annotation}) and type-level
* ({@code @within}) placement are already supported by CDI when the binding targets METHOD/TYPE.
*/
@Interceptor
@PremiumEndpoint
@Priority(Interceptor.Priority.APPLICATION)
public class PremiumEndpointAspect {
private final boolean runningProOrHigher;
public PremiumEndpointAspect(@Qualifier("runningProOrHigher") boolean runningProOrHigher) {
@Inject
public PremiumEndpointAspect(@Named("runningProOrHigher") boolean runningProOrHigher) {
this.runningProOrHigher = runningProOrHigher;
}
@Around(
"@annotation(stirling.software.proprietary.security.config.PremiumEndpoint) || @within(stirling.software.proprietary.security.config.PremiumEndpoint)")
public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable {
@AroundInvoke
public Object checkPremiumAccess(InvocationContext ctx) throws Exception {
if (!runningProOrHigher) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This endpoint requires a Server or Enterprise license");
throw new WebApplicationException(
"This endpoint requires a Server or Enterprise license",
Response.Status.FORBIDDEN);
}
return joinPoint.proceed();
return ctx.proceed();
}
}
@@ -1,38 +1,39 @@
package stirling.software.proprietary.security.configuration;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import stirling.software.common.model.ApplicationProperties;
@Configuration
@EnableCaching
// TODO: Migration required - Spring's @EnableCaching + a programmatic CaffeineCacheManager
// @Bean has no direct Quarkus equivalent. Quarkus caching is annotation-driven
// (io.quarkus.cache.@CacheResult / @CacheInvalidate / @CacheName) and configured
// declaratively in application.properties, e.g.:
// quarkus.cache.caffeine."<cache-name>".maximum-size=1000
// quarkus.cache.caffeine."<cache-name>".expire-after-write=<keyRetentionDays>D
// quarkus.cache.caffeine."<cache-name>".metrics-enabled=true # was .recordStats()
// The expire-after-write here was derived at runtime from
// applicationProperties.getSecurity().getJwt().getKeyRetentionDays(); since Quarkus
// cache config is static, either pin a static value in application.properties or use
// io.quarkus.cache.CacheManager#getCache(...) programmatically to rebuild the cache
// with a runtime TTL. Annotate the relevant cached methods (previously relying on the
// Spring CacheManager) with @CacheResult(cacheName = "<cache-name>").
@ApplicationScoped
public class CacheConfig {
private final ApplicationProperties applicationProperties;
@Autowired
@Inject
public CacheConfig(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Bean
public CacheManager cacheManager() {
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(
Caffeine.newBuilder()
.maximumSize(1000) // Make configurable?
.expireAfterWrite(Duration.ofDays(keyRetentionDays))
.recordStats());
return cacheManager;
/**
* Retained for reference: the JWT key retention window (in days) that previously
* drove Caffeine's expireAfterWrite. Used by the Quarkus cache migration described
* above to derive the TTL for the corresponding named cache.
*/
public int getKeyRetentionDays() {
return applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
}
}
@@ -1,19 +1,22 @@
package stirling.software.proprietary.security.configuration;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.util.Locale;
import java.util.Properties;
import java.util.logging.Logger;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.jdbc.DatabaseDriver;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import io.quarkus.arc.profile.UnlessBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -22,27 +25,47 @@ import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
/**
* MIGRATION NOTES (Spring -> Quarkus CDI):
*
* <ul>
* <li>{@code @Configuration} -> {@code @ApplicationScoped}; {@code @Bean} -> {@code @Produces}.
* <li>{@code @Qualifier("runningProOrHigher")} ctor param -> {@code @Inject} ctor with
* {@code @Named(...)} on the parameter (the producer lives in common {@code AppConfig}).
* <li>{@code @Profile("!saas")} on the producer -> {@code @UnlessBuildProfile("saas")} so the SaaS
* Postgres datasource shadows this H2 default exactly as the old profile override did.
* <li>{@code @Primary} dropped - the SaaS producer is selected by build profile instead of by
* primary/override semantics.
* <li>{@code @EnableJpaRepositories}/{@code @EntityScan} removed - Quarkus auto-discovers JPA
* entities and Panache repositories across the Jandex index; no explicit base-package wiring
* is needed.
* <li>Spring Boot {@code DataSourceBuilder}/{@code DatabaseDriver} (no Quarkus equivalent) ->
* replaced with a minimal {@link DriverManager}-backed {@link DataSource}. This preserves the
* original lazy-connect semantics of {@code DataSourceBuilder.build()} (no connection is opened
* until {@link DataSource#getConnection()} is called); the driver class-name strings are the
* same literals {@code DatabaseDriver.H2/POSTGRESQL.getDriverClassName()} returned.
* </ul>
*
* <p>TODO: Migration required - the idiomatic Quarkus approach is to drop this programmatic producer
* entirely and configure the datasource via {@code quarkus.datasource.*} (jdbc-url / username /
* password / db-kind), letting Agroal own the connection pool. This producer is retained to preserve
* the runtime "custom database" toggle (premium + {@code datasource.enableCustomDatabase}) that
* selects between the bundled H2 file DB and a user-supplied URL at startup - static config cannot
* express that branch on its own. The {@code DriverManager} datasource below is intentionally
* unpooled; if connection pooling is required it should be obtained from the Agroal-managed default
* datasource instead.
*/
@Slf4j
@Getter
@Configuration
@EnableJpaRepositories(
basePackages = {
"stirling.software.proprietary.security.database.repository",
"stirling.software.proprietary.security.repository",
"stirling.software.proprietary.repository",
"stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository",
"stirling.software.proprietary.policy.store"
})
@EntityScan({
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model",
"stirling.software.proprietary.policy.store"
})
@ApplicationScoped
public class DatabaseConfig {
/** {@code org.springframework.boot.jdbc.DatabaseDriver.H2.getDriverClassName()}. */
private static final String H2_DRIVER_CLASS_NAME = "org.h2.Driver";
/** {@code org.springframework.boot.jdbc.DatabaseDriver.POSTGRESQL.getDriverClassName()}. */
private static final String POSTGRESQL_DRIVER_CLASS_NAME = "org.postgresql.Driver";
public final String DATASOURCE_DEFAULT_URL;
public static final String DATASOURCE_URL_TEMPLATE = "jdbc:%s://%s:%4d/%s";
@@ -51,9 +74,10 @@ public class DatabaseConfig {
private final ApplicationProperties.Datasource datasource;
private final boolean runningProOrHigher;
@Inject
public DatabaseConfig(
ApplicationProperties.Datasource datasource,
@Qualifier("runningProOrHigher") boolean runningProOrHigher) {
@Named("runningProOrHigher") boolean runningProOrHigher) {
DATASOURCE_DEFAULT_URL =
"jdbc:h2:file:"
+ InstallationPathConfig.getConfigPath()
@@ -71,21 +95,19 @@ public class DatabaseConfig {
* @return a <code>DataSource</code> using the configuration settings in the settings.yml
* @throws UnsupportedProviderException if the type of database selected is not supported
*/
@Bean
@Qualifier("dataSource")
@Primary
@Profile("!saas")
@Produces
@ApplicationScoped
@Named("dataSource")
@UnlessBuildProfile("saas")
public DataSource dataSource() throws UnsupportedProviderException {
DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
if (!runningProOrHigher || !datasource.isEnableCustomDatabase()) {
return useDefaultDataSource(dataSourceBuilder);
return useDefaultDataSource();
}
return useCustomDataSource(dataSourceBuilder);
return useCustomDataSource();
}
private DataSource useDefaultDataSource(DataSourceBuilder<?> dataSourceBuilder) {
private DataSource useDefaultDataSource() {
// Support AOT training: override URL via system property to avoid H2 file lock
// conflicts when the AOT RECORD phase starts a second Spring context
String overrideUrl = System.getProperty("stirling.datasource.url");
@@ -96,38 +118,39 @@ public class DatabaseConfig {
log.info("Using default H2 database");
dataSourceBuilder
.url(url)
.driverClassName(DatabaseDriver.H2.getDriverClassName())
.username(DEFAULT_USERNAME);
return dataSourceBuilder.build();
return new SimpleDriverDataSource(H2_DRIVER_CLASS_NAME, url, DEFAULT_USERNAME, null);
}
@ConditionalOnBooleanProperty(name = "premium.enabled")
private DataSource useCustomDataSource(DataSourceBuilder<?> dataSourceBuilder)
throws UnsupportedProviderException {
// TODO: Migration required - the Spring @ConditionalOnBooleanProperty(name = "premium.enabled")
// gate is not expressible on a private helper under CDI. The custom-database path is already
// guarded at runtime by the runningProOrHigher + datasource.enableCustomDatabase checks in
// dataSource(); if a separate premium.enabled toggle is still required, read it via
// org.eclipse.microprofile.config.Config (e.g. premium.enabled) inside dataSource() before
// calling this method.
private DataSource useCustomDataSource() throws UnsupportedProviderException {
log.info("Using custom database configuration");
if (!datasource.getCustomDatabaseUrl().isBlank()) {
if (datasource.getCustomDatabaseUrl().contains("postgresql")) {
dataSourceBuilder.driverClassName(DatabaseDriver.POSTGRESQL.getDriverClassName());
}
String driverClassName;
String url;
dataSourceBuilder.url(datasource.getCustomDatabaseUrl());
if (!datasource.getCustomDatabaseUrl().isBlank()) {
driverClassName =
datasource.getCustomDatabaseUrl().contains("postgresql")
? POSTGRESQL_DRIVER_CLASS_NAME
: null;
url = datasource.getCustomDatabaseUrl();
} else {
dataSourceBuilder.driverClassName(getDriverClassName(datasource.getType()));
dataSourceBuilder.url(
driverClassName = getDriverClassName(datasource.getType());
url =
generateCustomDataSourceUrl(
datasource.getType(),
datasource.getHostName(),
datasource.getPort(),
datasource.getName()));
datasource.getName());
}
dataSourceBuilder.username(datasource.getUsername());
dataSourceBuilder.password(datasource.getPassword());
return dataSourceBuilder.build();
return new SimpleDriverDataSource(
driverClassName, url, datasource.getUsername(), datasource.getPassword());
}
/**
@@ -159,11 +182,11 @@ public class DatabaseConfig {
return switch (driver) {
case H2 -> {
log.debug("H2 driver selected");
yield DatabaseDriver.H2.getDriverClassName();
yield H2_DRIVER_CLASS_NAME;
}
case POSTGRESQL -> {
log.debug("Postgres driver selected");
yield DatabaseDriver.POSTGRESQL.getDriverClassName();
yield POSTGRESQL_DRIVER_CLASS_NAME;
}
default -> {
log.warn("{} driver selected", driverName);
@@ -176,4 +199,89 @@ public class DatabaseConfig {
throw new UnsupportedProviderException(driverName + " is not currently supported");
}
}
/**
* Minimal unpooled {@link DataSource} backed by {@link DriverManager}, replacing Spring Boot's
* {@code DataSourceBuilder}. Connections are opened lazily on {@link #getConnection()} (matching
* {@code DataSourceBuilder.build()} semantics) and the optional driver class is loaded eagerly so
* it self-registers with {@link DriverManager}.
*/
private static final class SimpleDriverDataSource implements DataSource {
private final String url;
private final String username;
private final String password;
private PrintWriter logWriter;
private int loginTimeout;
SimpleDriverDataSource(
String driverClassName, String url, String username, String password) {
if (driverClassName != null && !driverClassName.isBlank()) {
try {
Class.forName(driverClassName);
} catch (ClassNotFoundException e) {
log.warn("JDBC driver {} not found on the classpath", driverClassName, e);
}
}
this.url = url;
this.username = username;
this.password = password;
}
@Override
public Connection getConnection() throws SQLException {
return getConnection(username, password);
}
@Override
public Connection getConnection(String user, String pass) throws SQLException {
Properties props = new Properties();
if (user != null) {
props.setProperty("user", user);
}
if (pass != null) {
props.setProperty("password", pass);
}
return DriverManager.getConnection(url, props);
}
@Override
public PrintWriter getLogWriter() {
return logWriter;
}
@Override
public void setLogWriter(PrintWriter out) {
this.logWriter = out;
}
@Override
public void setLoginTimeout(int seconds) {
this.loginTimeout = seconds;
}
@Override
public int getLoginTimeout() {
return loginTimeout;
}
@Override
public Logger getParentLogger() throws SQLFeatureNotSupportedException {
throw new SQLFeatureNotSupportedException();
}
@Override
public <T> T unwrap(Class<T> iface) throws SQLException {
if (iface.isInstance(this)) {
return iface.cast(this);
}
throw new SQLException("DataSource of type [" + getClass().getName()
+ "] cannot be unwrapped as [" + iface.getName() + "]");
}
@Override
public boolean isWrapperFor(Class<?> iface) {
return iface.isInstance(this);
}
}
}
@@ -2,13 +2,19 @@ package stirling.software.proprietary.security.configuration;
import java.util.Properties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
// TODO: Migration required - org.springframework.mail.javamail.* is Spring's mail abstraction, NOT
// Spring DI. There is no Quarkus equivalent that the EmailService collaborator can consume without
// also migrating EmailService (which uses MimeMessage/MimeMessageHelper). Quarkus ships
// quarkus-mailer (io.quarkus.mailer.Mailer / ReactiveMailer) with a different API. Keep the Spring
// Mail types here until EmailService is migrated together, then swap the producer to expose a
// Quarkus Mailer (configured via quarkus.mailer.* in application.properties).
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@@ -18,15 +24,26 @@ import stirling.software.common.model.ApplicationProperties;
* email server settings from the configuration (ApplicationProperties) and configures the mail
* client (JavaMailSender).
*/
@Configuration
@ApplicationScoped
@Slf4j
@AllArgsConstructor
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
public class MailConfig {
private final ApplicationProperties applicationProperties;
@Bean
@Inject
public MailConfig(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
// TODO: Migration required - the original bean was guarded by
// @ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false).
// There is no @ConditionalOnProperty in Quarkus. A build-time toggle could use
// @io.quarkus.arc.lookup.LookupIfProperty(name = "mail.enabled", stringValue = "true"), but
// mail.enabled is a runtime property (ApplicationProperties.Mail#isEnabled). Consumers already
// guard on applicationProperties.getMail().isEnabled() at call time, so the bean is always
// produced and the runtime guard remains the source of truth.
@Produces
@ApplicationScoped
public JavaMailSender javaMailSender() {
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
@@ -1,16 +1,30 @@
package stirling.software.proprietary.security.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/** Standalone {@link PasswordEncoder} bean. */
@Configuration
/**
* Standalone {@link PasswordEncoder} producer.
*
* <p>TODO: Migration required - spring-security-crypto is no longer on the Quarkus classpath. The
* {@link PasswordEncoder} / {@link BCryptPasswordEncoder} types must be replaced. Options: add a
* BCrypt library (e.g. at.favre.lib:bcrypt or org.mindrot:jbcrypt) and produce a thin local
* PasswordEncoder abstraction, or use io.quarkus.elytron.security.common.BcryptUtil. The
* org.springframework.security imports below are retained only so the bean shape/return type stays
* intact for the consuming services (UserService, SecurityConfiguration) until the encoder
* abstraction is ported across all three files together.
*/
@ApplicationScoped
public class PasswordEncoderConfig {
@Bean
@Produces
@ApplicationScoped
public PasswordEncoder passwordEncoder() {
// TODO: Migration required - replace BCryptPasswordEncoder once a Quarkus-compatible
// BCrypt implementation is wired in (see class-level note).
return new BCryptPasswordEncoder();
}
}
@@ -1,22 +1,34 @@
package stirling.software.proprietary.security.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor;
@Configuration
/**
* TODO: Migration required - Spring MVC's WebMvcConfigurer / InterceptorRegistry has no Quarkus
* (JAX-RS / RESTEasy Reactive) equivalent, so this registration class cannot be ported directly.
*
* <p>This class only existed to bind {@link ParticipantRateLimitInterceptor} to the path pattern
* "/api/v1/workflow/participant/**". In Quarkus the rate-limiting logic should instead live in a
* {@code jakarta.ws.rs.container.ContainerRequestFilter} annotated with {@code @Provider} (and
* scoped to the participant endpoints via a {@code @NameBinding} annotation or by inspecting
* {@code UriInfo.getPath()} inside the filter). Once {@link ParticipantRateLimitInterceptor} is
* converted to such a filter, the registration is automatic (Quarkus discovers @Provider filters)
* and this class can be deleted entirely.
*
* <p>Kept as an @ApplicationScoped bean (with no behavior) so the build still discovers the type;
* the collaborator file {@link ParticipantRateLimitInterceptor} must be migrated to complete this.
*/
@ApplicationScoped
@RequiredArgsConstructor
public class ProprietaryWebMvcConfig implements WebMvcConfigurer {
public class ProprietaryWebMvcConfig {
private final ParticipantRateLimitInterceptor participantRateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(participantRateLimitInterceptor)
.addPathPatterns("/api/v1/workflow/participant/**");
}
// TODO: Migration required - the interceptor registration below was removed:
// registry.addInterceptor(participantRateLimitInterceptor)
// .addPathPatterns("/api/v1/workflow/participant/**");
// Re-implement as a JAX-RS ContainerRequestFilter bound to that path (see class javadoc).
}
@@ -3,75 +3,115 @@ package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml5AuthenticationRequestResolver;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
import stirling.software.proprietary.security.JwtAuthenticationEntryPoint;
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestResolver;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
/**
* Security configuration migrated from a Spring {@code @Configuration}/{@code @EnableWebSecurity}
* class to a Quarkus CDI bean.
*
* <p>TODO: Migration required - This class was built entirely around the Spring Security
* {@code HttpSecurity} DSL and {@code SecurityFilterChain} beans, which have NO direct Quarkus
* equivalent. The HTTP security model must be re-expressed declaratively/imperatively:
*
* <ul>
* <li><b>HTTP path policies / authorization</b> (the {@code authorizeHttpRequests} rules: permit
* static resources + public auth endpoints via {@code RequestUriUtils}, authenticate
* everything else; permit-all when login is disabled) -> configure {@code quarkus.http.auth.*}
* permission sets in {@code application.properties}, or implement a
* {@code jakarta.ws.rs.container.ContainerRequestFilter} that reuses
* {@link stirling.software.common.util.RequestUriUtils#isStaticResource} and
* {@code isPublicAuthEndpoint}.
* <li><b>Two ordered filter chains</b> ({@code samlFilterChain} {@code @Order(1)} matching
* {@code /saml2/**} + {@code /login/saml2/**} with {@code IF_REQUIRED} sessions when SAML2 is
* active on pro+, and the catch-all {@code filterChain} {@code @Order(2)} STATELESS) -> Quarkus
* has a single request pipeline; path-specific behaviour must be keyed off the request path
* inside filters/policies. Session creation policy maps to {@code quarkus.http.auth.*} +
* {@code quarkus-undertow} session config.
* <li><b>CSRF disabled / CORS</b> -> {@code quarkus.http.cors.*} (see
* {@link #buildCorsConfig()} which preserves the original origins/methods/headers values) and
* {@code quarkus.http.csrf} config.
* <li><b>X-Frame-Options</b> (DENY / SAMEORIGIN / DISABLED driven by
* {@code securityProperties.getXFrameOptions()}, auto-disabled when login is off) -> a response
* filter or {@code quarkus.http.header."X-Frame-Options"} config; the decision logic is kept in
* {@link #resolveXFrameOptions()}.
* <li><b>Servlet filters</b> ({@link UserAuthenticationFilter}, {@link JwtAuthenticationFilter},
* {@link IPRateLimitingFilter}) -> register as {@code jakarta.servlet.Filter} via
* quarkus-undertow or convert to {@code ContainerRequestFilter}; ordering (userAuth before
* UsernamePasswordAuthenticationFilter, jwt before userAuth) must be reproduced via
* {@code @jakarta.annotation.Priority}. Note IPRateLimitingFilter was already disabled in the
* Spring chain (see original TODO about async-dispatch / StreamingResponseBody).
* <li><b>Form login / logout / remember-me</b> ({@code formLogin} -> {@code /login} page +
* {@code /perform_login}, {@code CustomAuthenticationSuccessHandler}/{@code FailureHandler},
* {@code logout} -> {@code CustomLogoutSuccessHandler} clearing JSESSIONID/remember-me/
* stirling_jwt cookies, {@code rememberMe} -> {@link JPATokenRepositoryImpl} with 14-day
* validity) -> there is no Quarkus equivalent of the form-login/remember-me machinery. Since
* this is a v2 API-driven auth flow ({@code /api/v1/auth/login}), reimplement as custom JAX-RS
* endpoints + the existing handlers, or wire quarkus-oidc/custom IdentityProvider.
* <li><b>OAuth2 login</b> ({@code oauth2Login} -> {@code TauriAuthorizationRequestResolver},
* {@code CustomOAuth2UserService}, {@code CustomOAuth2Authentication*Handler},
* {@code GrantedAuthoritiesMapper}, {@code ClientRegistrationRepository}) -> migrate to
* quarkus-oidc ({@code quarkus.oidc.*}, {@code @io.quarkus.oidc.IdToken},
* {@code SecurityIdentityAugmentor}); keep the claim/user-mapping logic in the existing
* services.
* <li><b>SAML2 login</b> ({@code saml2Login} -> {@code OpenSaml5AuthenticationProvider},
* {@code CustomSaml2ResponseAuthenticationConverter},
* {@code CustomSaml2Authentication*Handler}, {@code RelyingPartyRegistrationRepository},
* {@code OpenSaml5AuthenticationRequestResolver}, {@code saml2Metadata}) -> there is NO Quarkus
* SAML extension. Keep all OpenSAML 5 logic and rehost the SP on a Jakarta {@code @WebServlet}
* (dnulnets/quarkus-saml pattern). The Spring {@code org.springframework.security.saml2.*} glue
* has been removed here.
* <li><b>HttpFirewall</b> ({@code StrictHttpFirewall} relaxed to allow non-ASCII header/param
* values for reverse proxies like Authelia) -> Spring-Security-only; Quarkus/Vert.x performs
* its own request validation. The allowed-character patterns are preserved in
* {@link #HEADER_VALUE_PATTERN}/{@link #PARAM_VALUE_PATTERN} for reuse if a custom validator is
* added.
* <li><b>DaoAuthenticationProvider</b> + {@code PasswordEncoder} ({@code @EnableMethodSecurity},
* {@code ProviderManager}) -> replace with a Quarkus {@code IdentityProvider} backed by
* {@link CustomUserDetailsService}; method-level security maps to
* {@code jakarta.annotation.security.@RolesAllowed}.
* </ul>
*
* <p>The collaborators are still injected so the wiring is preserved for the reimplementation. The
* reusable, non-Spring helper logic (CORS values, X-Frame-Options decision, firewall char patterns,
* filter/repository factories) is retained as plain methods/producers below.
*
* <p>TODO: Migration required - this bean was {@code @DependsOn("runningProOrHigher")} and
* {@code @Profile("!saas")}. The dependency ordering is approximated by injecting the
* {@code runningProOrHigher} flag; the {@code !saas} profile gate maps to a Quarkus build profile -
* use {@code @io.quarkus.arc.profile.UnlessBuildProfile("saas")} or
* {@code @io.quarkus.arc.lookup.LookupIfProperty} (adjust to the actual saas profile/property
* toggle).
*/
@Slf4j
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@DependsOn("runningProOrHigher")
@Profile("!saas")
@ApplicationScoped
public class SecurityConfiguration {
// Allowed-character patterns preserved from the original StrictHttpFirewall relaxation
// (non-ASCII allowed for reverse proxies, control chars rejected). See class-level TODO.
static final Pattern HEADER_VALUE_PATTERN =
Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*");
static final Pattern PARAM_VALUE_PATTERN =
Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]\\r\\n]*");
private final CustomUserDetailsService userDetailsService;
private final UserService userService;
private final boolean loginEnabledValue;
@@ -86,21 +126,25 @@ public class SecurityConfiguration {
private final LoginAttemptService loginAttemptService;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ClientRegistrationRepository clientRegistrationRepository;
private final PasswordEncoder passwordEncoder;
private final stirling.software.proprietary.service.AiUserDataService aiUserDataService;
// TODO: Migration required - the following Spring-Security collaborators were injected as
// @Autowired(required=false) optional beans and consumed only inside the removed HttpSecurity
// DSL (GrantedAuthoritiesMapper, RelyingPartyRegistrationRepository,
// OpenSaml5AuthenticationRequestResolver, ClientRegistrationRepository, PasswordEncoder). They
// are dropped here because their types are Spring-Security-only; reintroduce equivalents
// (quarkus-oidc client config, OpenSAML 5 SP wiring, a CDI password hasher) during the
// OAuth2/SAML2/auth reimplementation described in the class javadoc.
@Inject
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
CustomUserDetailsService userDetailsService,
@Lazy UserService userService,
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
UserService userService,
@Named("loginEnabled") boolean loginEnabledValue,
@Named("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties applicationProperties,
ApplicationProperties.Security securityProperties,
@@ -109,14 +153,7 @@ public class SecurityConfiguration {
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
LoginAttemptService loginAttemptService,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver,
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService,
PasswordEncoder passwordEncoder,
stirling.software.proprietary.service.AiUserDataService aiUserDataService) {
this.userDetailsService = userDetailsService;
this.userService = userService;
@@ -131,358 +168,135 @@ public class SecurityConfiguration {
this.loginAttemptService = loginAttemptService;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
this.clientRegistrationRepository = clientRegistrationRepository;
this.licenseSettingsService = licenseSettingsService;
this.passwordEncoder = passwordEncoder;
this.aiUserDataService = aiUserDataService;
}
/**
* Configures HttpFirewall to allow non-ASCII characters in header values. This fixes issues
* with reverse proxies (like Authelia) that may set headers with non-ASCII characters (e.g.,
* "Remote-User: Dvořák").
* Reusable CORS settings preserved from the original {@code corsConfigurationSource()} bean.
*
* <p>By default, StrictHttpFirewall rejects header values containing non-ASCII characters. This
* configuration allows valid UTF-8 encoded characters while maintaining security.
* <p>TODO: Migration required - the Spring {@code CorsConfigurationSource}/
* {@code UrlBasedCorsConfigurationSource} types are removed. Apply these values via
* {@code quarkus.http.cors.*} in {@code application.properties} (origins, methods, headers,
* exposed-headers, access-control-allow-credentials=true, access-control-max-age=PT1H) or a
* {@code ContainerResponseFilter}. The origin resolution from
* {@code applicationProperties.getSystem().getCorsAllowedOrigins()} (defaulting to "*") is kept
* here so it can feed whichever mechanism is chosen.
*
* @return Configured HttpFirewall that allows non-ASCII characters in headers
* @return the resolved allowed origin patterns ("*" when none configured)
*/
@Bean
public HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
// Allow non-ASCII characters but continue to reject control characters such as newlines.
// Pattern adapted from Spring Security's StrictHttpFirewall documentation.
Pattern allowedChars = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*");
firewall.setAllowedHeaderValues(
headerValue -> headerValue != null && allowedChars.matcher(headerValue).matches());
// Allow non-ASCII characters and newlines in parameter values.
Pattern allowedParamChars = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]\\r\\n]*");
firewall.setAllowedParameterValues(
parameterValue ->
parameterValue != null
&& allowedParamChars.matcher(parameterValue).matches());
return firewall;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> buildCorsConfig() {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
configuredOrigins);
} else {
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
log.info(
"No CORS allowed origins configured in settings.yml"
+ " (system.corsAllowedOrigins); allowing all origins.");
return configuredOrigins;
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
// Default to allowing all origins when nothing is configured
log.info(
"No CORS allowed origins configured in settings.yml"
+ " (system.corsAllowedOrigins); allowing all origins.");
return List.of("*");
}
@Bean
@Order(1)
public SecurityFilterChain samlFilterChain(
HttpSecurity http,
@Lazy IPRateLimitingFilter rateLimitingFilter,
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
http.securityMatcher("/saml2/**", "/login/saml2/**");
// Preserved CORS value sets (apply via quarkus.http.cors.* - see buildCorsConfig() TODO).
static final List<String> CORS_ALLOWED_METHODS =
List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS");
static final List<String> CORS_ALLOWED_HEADERS =
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN");
static final List<String> CORS_EXPOSED_HEADERS =
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type");
SessionCreationPolicy sessionPolicy =
(securityProperties.isSaml2Active() && runningProOrHigher)
? SessionCreationPolicy.IF_REQUIRED
: SessionCreationPolicy.STATELESS;
return configureSecurity(http, rateLimitingFilter, jwtAuthenticationFilter, sessionPolicy);
}
@Bean
@Order(2)
public SecurityFilterChain filterChain(
HttpSecurity http,
@Lazy IPRateLimitingFilter rateLimitingFilter,
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
SessionCreationPolicy sessionPolicy = SessionCreationPolicy.STATELESS;
return configureSecurity(http, rateLimitingFilter, jwtAuthenticationFilter, sessionPolicy);
}
private SecurityFilterChain configureSecurity(
HttpSecurity http,
@Lazy IPRateLimitingFilter rateLimitingFilter,
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter,
SessionCreationPolicy sessionPolicy)
throws Exception {
// Enable CORS only if we have configured origins
CorsConfigurationSource corsSource = corsConfigurationSource();
if (corsSource != null) {
http.cors(cors -> cors.configurationSource(corsSource));
} else {
// Explicitly disable CORS when no origins are configured
http.cors(CorsConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
// Configure X-Frame-Options based on settings.yml configuration
// When login is disabled, automatically disable X-Frame-Options to allow embedding
/**
* Resolves the desired X-Frame-Options header value, preserving the original decision logic.
*
* <p>TODO: Migration required - apply the returned value via a response filter or
* {@code quarkus.http.header} config (Spring's {@code HeadersConfigurer} is gone).
*
* @return "DISABLED", "SAMEORIGIN" or "DENY"
*/
String resolveXFrameOptions() {
// When login is disabled, X-Frame-Options is disabled to allow embedding.
if (!loginEnabledValue) {
http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable));
} else {
String xFrameOption = securityProperties.getXFrameOptions();
if (xFrameOption != null) {
http.headers(
headers -> {
if ("DISABLED".equalsIgnoreCase(xFrameOption)) {
headers.frameOptions(FrameOptionsConfig::disable);
} else if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) {
headers.frameOptions(FrameOptionsConfig::sameOrigin);
} else {
// Default to DENY
headers.frameOptions(FrameOptionsConfig::deny);
}
});
} else {
// If not configured, use default DENY
http.headers(headers -> headers.frameOptions(FrameOptionsConfig::deny));
}
return "DISABLED";
}
if (loginEnabledValue) {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// TODO: IPRateLimitingFilter disabled (limit is 1M, no-op) and raw Filter
// impl causes Spring Security async dispatch bug (response already committed
// errors on StreamingResponseBody endpoints). Re-enable once converted to
// OncePerRequestFilter with proper config-driven limits.
// .addFilterBefore(rateLimitingFilter,
// UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
http.sessionManagement(
sessionManagement -> sessionManagement.sessionCreationPolicy(sessionPolicy));
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
// Configure exception handling for API endpoints
http.exceptionHandling(
exceptions ->
exceptions.defaultAuthenticationEntryPointFor(
jwtAuthenticationEntryPoint,
request -> {
String contextPath = request.getContextPath();
String requestURI = request.getRequestURI();
return requestURI.startsWith(contextPath + "/api/");
}));
http.logout(
logout ->
logout.logoutRequestMatcher(
PathPatternRequestMatcher.withDefaults()
.matcher("/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties,
appConfig,
jwtService,
aiUserDataService))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
http.rememberMe(
rememberMeConfigurer -> // Use the configurator directly
rememberMeConfigurer
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds( // 14 days
14 * 24 * 60 * 60)
.userDetailsService( // Your existing UserDetailsService
userDetailsService)
.useSecureCookie( // Enable secure cookie
true)
.rememberMeParameter( // Form parameter name
"remember-me")
.rememberMeCookieName( // Cookie name
"remember-me")
.alwaysRemember(false));
http.authorizeHttpRequests(
authz ->
authz.requestMatchers(
req -> {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
// Check if it's a public auth endpoint or static
// resource
return RequestUriUtils.isStaticResource(
contextPath, uri)
|| RequestUriUtils.isPublicAuthEndpoint(
uri, contextPath);
})
.permitAll()
.anyRequest()
.authenticated());
// Handle User/Password Logins
if (securityProperties.isUserPass()) {
// v2: Authentication is handled via API (/api/v1/auth/login), not form login
// We configure form login to handle Spring Security redirects,
// but use /perform_login as the processing URL so /login remains a React route
http.formLogin(
formLogin ->
formLogin
.loginPage("/login") // Redirect here when unauthenticated
.loginProcessingUrl(
"/perform_login") // Process form posts here (not
// /login)
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService,
userService,
jwtService))
.failureHandler(
new CustomAuthenticationFailureHandler(
loginAttemptService, userService))
.permitAll());
}
// Handle OAUTH2 Logins
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 -> {
oauth2.loginPage("/login")
.authorizationEndpoint(
authorizationEndpoint -> {
if (clientRegistrationRepository != null) {
authorizationEndpoint
.authorizationRequestResolver(
new TauriAuthorizationRequestResolver(
clientRegistrationRepository));
}
})
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService,
applicationProperties))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties
.getOauth2(),
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll();
});
}
// Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) {
OpenSaml5AuthenticationProvider authenticationProvider =
new OpenSaml5AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(
new CustomSaml2ResponseAuthenticationConverter(userService));
http.authenticationProvider(authenticationProvider)
.saml2Login(
saml2 -> {
try {
saml2.loginPage("/login")
.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))
.successHandler(
new CustomSaml2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getSaml2(),
userService,
jwtService,
licenseSettingsService,
applicationProperties))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
saml2AuthenticationRequestResolver);
} catch (Exception e) {
log.error("Error configuring SAML 2 login", e);
throw new RuntimeException(e);
}
})
.saml2Metadata(metadata -> {});
}
} else {
log.debug("Login is not enabled.");
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
String xFrameOption = securityProperties.getXFrameOptions();
if (xFrameOption == null) {
return "DENY";
}
return http.build();
if ("DISABLED".equalsIgnoreCase(xFrameOption)) {
return "DISABLED";
}
if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) {
return "SAMEORIGIN";
}
return "DENY";
}
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
// TODO: Migration required - samlFilterChain/filterChain/configureSecurity built the Spring
// SecurityFilterChain instances. Their behaviour is summarised in the class javadoc and must be
// reimplemented via Quarkus HTTP auth config + filters/IdentityProviders. The full original DSL
// is preserved in version control. No fabricated SecurityFilterChain is produced here.
@Bean
/**
* Produces the IP rate-limiting filter (plain {@code jakarta.servlet.Filter}, not a
* Spring-specific type, so it remains a CDI producer).
*
* <p>TODO: Migration required - registration/ordering must be handled by quarkus-undertow
* ({@code @WebFilter}) or a {@code ContainerRequestFilter}. This filter was already disabled in
* the original chain (limit is effectively a no-op at 1,000,000) pending conversion.
*/
@Produces
@ApplicationScoped
public IPRateLimitingFilter rateLimitingFilter() {
// Example limit TODO add config level
int maxRequestsPerIp = 1000000;
return new IPRateLimitingFilter(maxRequestsPerIp, maxRequestsPerIp);
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
/**
* Produces the persistent remember-me token repository.
*
* <p>TODO: Migration required - {@link JPATokenRepositoryImpl} implements the Spring Security
* {@code PersistentTokenRepository} interface (collaborator not yet migrated). The remember-me
* feature itself has no Quarkus equivalent (see class javadoc); the repository is still produced
* so the persistence logic is available to the reimplementation. Producer return type narrowed
* to the concrete class to avoid importing the Spring interface here.
*/
@Produces
@ApplicationScoped
public JPATokenRepositoryImpl persistentTokenRepository() {
return new JPATokenRepositoryImpl(persistentLoginRepository);
}
@Bean
/**
* Produces the JWT authentication filter.
*
* <p>TODO: Migration required - registration/ordering (must run before the user-auth filter) is
* no longer expressible via the Spring DSL; register via quarkus-undertow or convert to a
* {@code ContainerRequestFilter} with an explicit {@code @Priority}.
*/
@Produces
@ApplicationScoped
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(
jwtService,
@@ -1,6 +1,6 @@
package stirling.software.proprietary.security.configuration.ee;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
@@ -18,7 +18,7 @@ import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVeri
* changes in production typically warrant a restart anyway 3. UI reflects changes immediately
* (banner disappears, license status updates)
*/
@Service
@ApplicationScoped
@RequiredArgsConstructor
public class DynamicLicenseService implements LicenseServiceInterface {
@@ -2,52 +2,93 @@ package stirling.software.proprietary.security.configuration.ee;
import static stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import io.quarkus.arc.profile.IfBuildProfile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
import stirling.software.common.model.ApplicationProperties.Premium;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
/**
* Enterprise/Premium CDI producers (migrated from a Spring {@code @Configuration} class).
*
* <p>MIGRATION NOTES (Spring -> Quarkus CDI):
*
* <ul>
* <li>{@code @Configuration} -> {@code @ApplicationScoped}; {@code @Bean(name="x")} ->
* {@code @Produces @Named("x")}. These producers deliberately omit {@code @DefaultBean} so
* they OVERRIDE the {@code @DefaultBean} producers declared in
* {@code stirling.software.common.configuration.AppConfig} whenever the :proprietary module is
* on the classpath - this is the Quarkus idiom for Spring's profile-based bean override.
* <li>{@code @Profile("security & !saas")} -> {@code @IfBuildProfile("security")}. Spring's
* composite expression {@code security & !saas} cannot be expressed directly; the build-time
* profile gates "security". TODO: Migration required - the {@code & !saas} half of the
* expression is NOT honoured here. In :saas mode the (still to be migrated)
* {@code SaasLicenseOverride} producers must take precedence, and these enterprise producers
* must be suppressed, otherwise CDI will see two producers for the same {@code @Named}
* qualifier. Re-evaluate once :saas is migrated (e.g. gate on a runtime "saas" flag or split
* into separate build profiles).
* <li>{@code @Order(Ordered.HIGHEST_PRECEDENCE)} dropped - CDI has no ordered configuration
* classes; ordering was only used by Spring to win the bean override race, which {@code
* @DefaultBean}/no-{@code @DefaultBean} now handles.
* <li>Constructor-side {@code migrateEnterpriseSettingsToPremium(...)} call moved to a
* {@code @PostConstruct} method so it still runs once when the bean is created.
* <li>{@code boolean} producers marked {@code @Dependent}: a CDI normal scope (default
* {@code @ApplicationScoped} on a producer) requires a client proxy which is impossible for a
* primitive {@code boolean}, so {@code @Dependent} is used to recompute the value at each
* injection point.
* </ul>
*/
@ApplicationScoped
@IfBuildProfile("security")
public class EEAppConfig {
private final ApplicationProperties applicationProperties;
private final LicenseKeyChecker licenseKeyChecker;
@Inject
public EEAppConfig(
ApplicationProperties applicationProperties, LicenseKeyChecker licenseKeyChecker) {
this.applicationProperties = applicationProperties;
this.licenseKeyChecker = licenseKeyChecker;
}
@PostConstruct
void init() {
migrateEnterpriseSettingsToPremium(this.applicationProperties);
}
@Profile("security & !saas")
@Bean(name = "runningProOrHigher")
@Produces
@Dependent
@Named("runningProOrHigher")
public boolean runningProOrHigher() {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
}
@Profile("security & !saas")
@Bean(name = "license")
@Produces
@Named("license")
public String licenseType() {
return licenseKeyChecker.getPremiumLicenseEnabledResult().name();
}
@Profile("security & !saas")
@Bean(name = "runningEE")
@Produces
@Dependent
@Named("runningEE")
public boolean runningEnterprise() {
return licenseKeyChecker.getPremiumLicenseEnabledResult() == License.ENTERPRISE;
}
@Profile("security & !saas")
@Bean(name = "SSOAutoLogin")
@Produces
@Dependent
@Named("SSOAutoLogin")
public boolean ssoAutoLogin() {
boolean enabled = applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
if (enabled) {
@@ -10,7 +10,8 @@ import java.util.Locale;
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
import org.bouncycastle.crypto.signers.Ed25519Signer;
import org.bouncycastle.util.encoders.Hex;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -22,7 +23,7 @@ import stirling.software.common.util.RegexPatternUtils;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Service
@ApplicationScoped
@Slf4j
@RequiredArgsConstructor
public class KeygenLicenseVerifier {
@@ -5,13 +5,13 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import io.quarkus.runtime.StartupEvent;
import io.quarkus.scheduler.Scheduled;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
@@ -21,7 +21,7 @@ import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVeri
import stirling.software.proprietary.service.UserLicenseSettingsService;
@Slf4j
@Component
@ApplicationScoped
public class LicenseKeyChecker {
private static final String FILE_PREFIX = "file:";
@@ -37,10 +37,11 @@ public class LicenseKeyChecker {
// the latest tier rather than a stale cached value.
private volatile License premiumEnabledResult = License.NORMAL;
@Inject
public LicenseKeyChecker(
KeygenLicenseVerifier licenseService,
ApplicationProperties applicationProperties,
@Lazy UserLicenseSettingsService licenseSettingsService) {
UserLicenseSettingsService licenseSettingsService) {
this.licenseService = licenseService;
this.applicationProperties = applicationProperties;
this.licenseSettingsService = licenseSettingsService;
@@ -51,12 +52,15 @@ public class LicenseKeyChecker {
evaluateLicense();
}
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
public void onApplicationReady(@Observes StartupEvent event) {
synchronizeLicenseSettings();
}
@Scheduled(initialDelay = 604800000, fixedRate = 604800000) // 7 days in milliseconds
// TODO: Migration required - Spring used initialDelay=fixedRate=7d. Quarkus @Scheduled has no
// initialDelay equivalent for fixed-rate; "every=7d" fires the first run 7 days after start,
// which preserves the original initial-delay semantics. delayed="..." could add an extra offset
// if needed.
@Scheduled(every = "7d")
public void checkLicensePeriodically() {
try {
evaluateLicense();
@@ -9,17 +9,19 @@ import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -28,6 +30,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.multipart.FileUploadMultipartFile;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
@@ -37,20 +41,22 @@ import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker
* Admin controller for license management. Provides installation ID for Stripe checkout metadata
* and endpoints for managing license keys.
*/
@RestController
@ApplicationScoped
@Slf4j
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ADMIN')")
@jakarta.ws.rs.Path("/api/v1/admin")
@RolesAllowed("ADMIN")
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
public class AdminLicenseController {
@Autowired(required = false)
private LicenseKeyChecker licenseKeyChecker;
@Inject Instance<LicenseKeyChecker> licenseKeyCheckerInstance;
@Autowired(required = false)
private KeygenLicenseVerifier keygenLicenseVerifier;
@Inject Instance<KeygenLicenseVerifier> keygenLicenseVerifierInstance;
@Autowired private ApplicationProperties applicationProperties;
@Inject ApplicationProperties applicationProperties;
private LicenseKeyChecker licenseKeyChecker() {
return licenseKeyCheckerInstance.isResolvable() ? licenseKeyCheckerInstance.get() : null;
}
/**
* Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used
@@ -58,21 +64,24 @@ public class AdminLicenseController {
*
* @return Map containing the installation ID
*/
@GetMapping("/installation-id")
@GET
@jakarta.ws.rs.Path("/installation-id")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Get installation ID",
description =
"Returns the unique installation ID (MAC-based fingerprint) for this"
+ " self-hosted instance")
public ResponseEntity<Map<String, String>> getInstallationId() {
public Response getInstallationId() {
try {
String installationId = GeneralUtils.generateMachineFingerprint();
log.info("Admin requested installation ID: {}", installationId);
return ResponseEntity.ok(Map.of("installationId", installationId));
return Response.ok(Map.of("installationId", installationId)).build();
} catch (Exception e) {
log.error("Failed to generate installation ID", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to generate installation ID"));
return Response.serverError()
.entity(Map.of("error", "Failed to generate installation ID"))
.build();
}
}
@@ -83,26 +92,31 @@ public class AdminLicenseController {
* @param request Map containing the license key
* @return Response with success status, license type, and whether restart is required
*/
@PostMapping("/license-key")
@POST
@jakarta.ws.rs.Path("/license-key")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Save and activate license key",
description =
"Accepts a license key and activates it on the backend. Returns the activated"
+ " license type.")
public ResponseEntity<Map<String, Object>> saveLicenseKey(
@RequestBody Map<String, String> request) {
public Response saveLicenseKey(Map<String, String> request) {
String licenseKey = request.get("licenseKey");
// Reject null but allow empty string to clear license
if (licenseKey == null) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "License key is required"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("success", false, "error", "License key is required"))
.build();
}
try {
LicenseKeyChecker licenseKeyChecker = licenseKeyChecker();
if (licenseKeyChecker == null) {
return ResponseEntity.internalServerError()
.body(Map.of("success", false, "error", "License checker not available"));
return Response.serverError()
.entity(Map.of("success", false, "error", "License checker not available"))
.build();
}
// assume premium enabled when setting license key
applicationProperties.getPremium().setEnabled(true);
@@ -139,16 +153,17 @@ public class AdminLicenseController {
log.info("License key saved and activated: type={}", license.name());
return ResponseEntity.ok(response);
return Response.ok(response).build();
} catch (Exception e) {
log.error("Failed to save license key", e);
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"success",
false,
"error",
"Failed to activate license: " + e.getMessage()));
"Failed to activate license: " + e.getMessage()))
.build();
}
}
@@ -159,23 +174,28 @@ public class AdminLicenseController {
*
* @return Response with updated license information
*/
@PostMapping("/license/resync")
@POST
@jakarta.ws.rs.Path("/license/resync")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Resync license with Keygen",
description =
"Re-validates the existing license key with Keygen and updates local settings."
+ " Used after subscription upgrades.")
public ResponseEntity<Map<String, Object>> resyncLicense() {
public Response resyncLicense() {
try {
LicenseKeyChecker licenseKeyChecker = licenseKeyChecker();
if (licenseKeyChecker == null) {
return ResponseEntity.internalServerError()
.body(Map.of("success", false, "error", "License checker not available"));
return Response.serverError()
.entity(Map.of("success", false, "error", "License checker not available"))
.build();
}
String currentKey = applicationProperties.getPremium().getKey();
if (currentKey == null || currentKey.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "No license key configured"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("success", false, "error", "No license key configured"))
.build();
}
log.info("Resyncing license with Keygen");
@@ -199,16 +219,17 @@ public class AdminLicenseController {
license.name(),
premium.getMaxUsers());
return ResponseEntity.ok(response);
return Response.ok(response).build();
} catch (Exception e) {
log.error("Failed to resync license", e);
return ResponseEntity.internalServerError()
.body(
return Response.serverError()
.entity(
Map.of(
"success",
false,
"error",
"Failed to resync license: " + e.getMessage()));
"Failed to resync license: " + e.getMessage()))
.build();
}
}
@@ -218,16 +239,19 @@ public class AdminLicenseController {
*
* @return Map containing license information
*/
@GetMapping("/license-info")
@GET
@jakarta.ws.rs.Path("/license-info")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Get license information",
description =
"Returns information about the current license including type, enabled status,"
+ " and max users")
public ResponseEntity<Map<String, Object>> getLicenseInfo() {
public Response getLicenseInfo() {
try {
Map<String, Object> response = new HashMap<>();
LicenseKeyChecker licenseKeyChecker = licenseKeyChecker();
if (licenseKeyChecker != null) {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
response.put("licenseType", license.name());
@@ -245,11 +269,12 @@ public class AdminLicenseController {
response.put("licenseKey", premium.getKey());
}
return ResponseEntity.ok(response);
return Response.ok(response).build();
} catch (Exception e) {
log.error("Failed to get license info", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to retrieve license information"));
return Response.serverError()
.entity(Map.of("error", "Failed to retrieve license information"))
.build();
}
}
@@ -257,55 +282,64 @@ public class AdminLicenseController {
* Upload a license certificate file for offline activation. Accepts .lic or .cert files,
* validates the certificate format, saves to configs directory, and activates the license.
*
* @param file The license certificate file to upload
* @param fileUpload The license certificate file to upload
* @return Response with success status, license type, and file information
*/
@PostMapping(value = "/license-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@POST
@jakarta.ws.rs.Path("/license-file")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Upload license certificate file",
description =
"Upload a license certificate file (.lic, .cert) for offline activation."
+ " Validates the file format and activates the license.")
public ResponseEntity<Map<String, Object>> uploadLicenseFile(
@RequestParam("file") MultipartFile file) {
public Response uploadLicenseFile(@RestForm("file") FileUpload fileUpload) {
MultipartFile file = FileUploadMultipartFile.of(fileUpload);
// Validate file exists
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File is empty"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("success", false, "error", "File is empty"))
.build();
}
String filename = file.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid filename"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("success", false, "error", "Invalid filename"))
.build();
}
// Prevent path traversal and enforce single filename component
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"success",
false,
"error",
"Filename must not contain path separators or '..'"));
"Filename must not contain path separators or '..'"))
.build();
}
// Validate file extension
if (!isValidLicenseFile(filename)) {
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"success",
false,
"error",
"Invalid file type. Expected .lic or .cert"));
"Invalid file type. Expected .lic or .cert"))
.build();
}
// Check file size (max 1MB for license files)
if (file.getSize() > 1_048_576) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File too large. Maximum 1MB allowed"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("success", false, "error", "File too large. Maximum 1MB allowed"))
.build();
}
try {
@@ -319,13 +353,14 @@ public class AdminLicenseController {
String content = new String(fileBytes, StandardCharsets.UTF_8);
if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) {
log.warn("License upload rejected: invalid certificate header");
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"success",
false,
"error",
"Invalid license certificate format"));
"Invalid license certificate format"))
.build();
}
// Get config directory and target path
@@ -339,8 +374,9 @@ public class AdminLicenseController {
// Prevent directory traversal: ensure targetPath is inside configPath
if (!targetPath.startsWith(configPathAbs)) {
log.warn("License upload rejected: target path outside config path");
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid file path"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("success", false, "error", "Invalid file path"))
.build();
}
// Backup existing file if present
@@ -364,6 +400,7 @@ public class AdminLicenseController {
// Update settings with file reference (relative path)
String fileReference = "file:configs/" + filename;
LicenseKeyChecker licenseKeyChecker = licenseKeyChecker();
licenseKeyChecker.updateLicenseKey(fileReference);
// Get license status after activation
@@ -383,26 +420,28 @@ public class AdminLicenseController {
filename,
license.name());
return ResponseEntity.ok(response);
return Response.ok(response).build();
} catch (IOException e) {
log.error("Failed to save license file", e);
return ResponseEntity.internalServerError()
.body(
return Response.serverError()
.entity(
Map.of(
"success",
false,
"error",
"Failed to save license file: " + e.getMessage()));
"Failed to save license file: " + e.getMessage()))
.build();
} catch (Exception e) {
log.error("Failed to activate license from file", e);
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"success",
false,
"error",
"Failed to activate license: " + e.getMessage()));
"Failed to activate license: " + e.getMessage()))
.build();
}
}
@@ -14,26 +14,24 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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.RequestParam;
import org.springframework.web.util.HtmlUtils;
import io.quarkus.runtime.Quarkus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.AdminApi;
@@ -49,15 +47,18 @@ import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequ
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
// @AdminApi carries only the OpenAPI @Tag under JAX-RS; the @Path the removed @RequestMapping
// supplied must be declared explicitly. Fully-qualified @jakarta.ws.rs.Path is used to avoid a
// clash with the java.nio.file.Path import.
@AdminApi
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@ApplicationScoped
@jakarta.ws.rs.Path("/api/v1/admin/settings")
@RolesAllowed("ADMIN")
@Slf4j
public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
@Inject ApplicationProperties applicationProperties;
@Inject ObjectMapper objectMapper;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
@@ -86,7 +87,7 @@ public class AdminSettingsController {
"enterprisekey",
"licensekey"));
@GetMapping
@GET
@Operation(
summary = "Get all application settings",
description =
@@ -99,9 +100,8 @@ public class AdminSettingsController {
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettings(
@RequestParam(value = "includePending", defaultValue = "false")
boolean includePending) {
public Response getSettings(
@QueryParam("includePending") @DefaultValue("false") boolean includePending) {
log.debug("Admin requested all application settings (includePending={})", includePending);
// Convert ApplicationProperties to Map
@@ -117,10 +117,11 @@ public class AdminSettingsController {
// Mask sensitive fields after merging
Map<String, Object> maskedSettings = maskSensitiveFields(settings);
return ResponseEntity.ok(maskedSettings);
return Response.ok(maskedSettings).build();
}
@GetMapping("/delta")
@GET
@jakarta.ws.rs.Path("/delta")
@Operation(
summary = "Get pending settings changes",
description =
@@ -135,7 +136,7 @@ public class AdminSettingsController {
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingsDelta() {
public Response getSettingsDelta() {
Map<String, Object> response = new HashMap<>();
// Mask sensitive fields in pending changes
response.put("pendingChanges", maskSensitiveFields(new HashMap<>(pendingChanges)));
@@ -143,10 +144,10 @@ public class AdminSettingsController {
response.put("count", pendingChanges.size());
log.debug("Admin requested pending changes - found {} settings", pendingChanges.size());
return ResponseEntity.ok(response);
return Response.ok(response).build();
}
@PutMapping
@PUT
@Operation(
summary = "Update application settings (delta updates)",
description =
@@ -163,13 +164,13 @@ public class AdminSettingsController {
responseCode = "500",
description = "Failed to save settings to configuration file")
})
public ResponseEntity<Map<String, Object>> updateSettings(
@Valid @RequestBody UpdateSettingsRequest request) {
public Response updateSettings(@Valid UpdateSettingsRequest request) {
try {
Map<String, Object> settings = request.getSettings();
if (settings == null || settings.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "No settings provided to update"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "No settings provided to update"))
.build();
}
// Validate all settings first before applying any changes
@@ -178,19 +179,20 @@ public class AdminSettingsController {
Object value = entry.getValue();
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"error",
"Invalid setting key format: "
+ HtmlUtils.htmlEscape(key)));
"Invalid setting key format: " + htmlEscape(key)))
.build();
}
// Validate pipeline path settings
String validationError = validatePipelinePathSetting(key, value);
if (validationError != null) {
return ResponseEntity.badRequest()
.body(Map.of("error", HtmlUtils.htmlEscape(validationError)));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", htmlEscape(validationError)))
.build();
}
}
@@ -206,31 +208,36 @@ public class AdminSettingsController {
pendingChanges.put(key, value != null ? value : "");
}
return ResponseEntity.ok(
Map.of(
"message",
String.format(
"Successfully updated %d setting(s). Changes will take effect on"
+ " application restart.",
settings.size())));
return Response.ok(
Map.of(
"message",
String.format(
"Successfully updated %d setting(s). Changes will take effect on"
+ " application restart.",
settings.size())))
.build();
} catch (IOException e) {
log.error("Failed to save settings to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", GENERIC_FILE_ERROR));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", GENERIC_FILE_ERROR))
.build();
} catch (IllegalArgumentException e) {
log.error("Invalid setting key or value: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", GENERIC_INVALID_SETTING));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", GENERIC_INVALID_SETTING))
.build();
} catch (Exception e) {
log.error("Unexpected error while updating settings: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", GENERIC_SERVER_ERROR));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", GENERIC_SERVER_ERROR))
.build();
}
}
@GetMapping("/section/{sectionName}")
@GET
@jakarta.ws.rs.Path("/section/{sectionName}")
@Operation(
summary = "Get specific settings section",
description =
@@ -246,18 +253,19 @@ public class AdminSettingsController {
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingsSection(
@PathVariable String sectionName,
@RequestParam(defaultValue = "true") boolean includePending) {
public Response getSettingsSection(
@PathParam("sectionName") String sectionName,
@QueryParam("includePending") @DefaultValue("true") boolean includePending) {
try {
Object sectionData = getSectionData(sectionName);
if (sectionData == null) {
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
"Invalid section name: "
+ HtmlUtils.htmlEscape(sectionName)
+ htmlEscape(sectionName)
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES));
+ String.join(", ", VALID_SECTION_NAMES))
.build();
}
// Convert to Map for manipulation
@@ -279,19 +287,22 @@ public class AdminSettingsController {
"Admin requested settings section: {} (includePending={})",
sectionName,
includePending);
return ResponseEntity.ok(sectionMap);
return Response.ok(sectionMap).build();
} catch (IllegalArgumentException e) {
log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Invalid section name: " + HtmlUtils.htmlEscape(sectionName));
return Response.status(Response.Status.BAD_REQUEST)
.entity("Invalid section name: " + htmlEscape(sectionName))
.build();
} catch (Exception e) {
log.error("Error retrieving section {}: {}", sectionName, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to retrieve section.");
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Failed to retrieve section.")
.build();
}
}
@PutMapping("/section/{sectionName}")
@PUT
@jakarta.ws.rs.Path("/section/{sectionName}")
@Operation(
summary = "Update specific settings section",
description = "Update all settings within a specific section. Admin access required.")
@@ -306,23 +317,26 @@ public class AdminSettingsController {
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to save settings")
})
public ResponseEntity<Map<String, Object>> updateSettingsSection(
@PathVariable String sectionName, @Valid @RequestBody Map<String, Object> sectionData) {
public Response updateSettingsSection(
@PathParam("sectionName") String sectionName,
@Valid Map<String, Object> sectionData) {
try {
if (sectionData == null || sectionData.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "No section data provided to update"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "No section data provided to update"))
.build();
}
if (!isValidSectionName(sectionName)) {
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"error",
"Invalid section name: "
+ HtmlUtils.htmlEscape(sectionName)
+ htmlEscape(sectionName)
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES)));
+ String.join(", ", VALID_SECTION_NAMES)))
.build();
}
// Auto-enable premium features if license key is provided
@@ -342,12 +356,12 @@ public class AdminSettingsController {
Object value = entry.getValue();
if (!isValidSettingKey(fullKey)) {
return ResponseEntity.badRequest()
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"error",
"Invalid setting key format: "
+ HtmlUtils.htmlEscape(fullKey)));
"Invalid setting key format: " + htmlEscape(fullKey)))
.build();
}
log.info("Admin updating section setting: {} = {}", fullKey, value);
@@ -359,31 +373,36 @@ public class AdminSettingsController {
updatedCount++;
}
String escapedSectionName = HtmlUtils.htmlEscape(sectionName);
return ResponseEntity.ok(
Map.of(
"message",
String.format(
"Successfully updated %d setting(s) in section '%s'. Changes will take"
+ " effect on application restart.",
updatedCount, escapedSectionName)));
String escapedSectionName = htmlEscape(sectionName);
return Response.ok(
Map.of(
"message",
String.format(
"Successfully updated %d setting(s) in section '%s'. Changes will take"
+ " effect on application restart.",
updatedCount, escapedSectionName)))
.build();
} catch (IOException e) {
log.error("Failed to save section settings to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", GENERIC_FILE_ERROR));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", GENERIC_FILE_ERROR))
.build();
} catch (IllegalArgumentException e) {
log.error("Invalid section data: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", GENERIC_INVALID_SECTION));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", GENERIC_INVALID_SECTION))
.build();
} catch (Exception e) {
log.error("Unexpected error while updating section settings: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", GENERIC_SERVER_ERROR));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", GENERIC_SERVER_ERROR))
.build();
}
}
@GetMapping("/key/{key}")
@GET
@jakarta.ws.rs.Path("/key/{key}")
@Operation(
summary = "Get specific setting value",
description =
@@ -399,17 +418,19 @@ public class AdminSettingsController {
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingValue(@PathVariable String key) {
public Response getSettingValue(@PathParam("key") String key) {
try {
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
return Response.status(Response.Status.BAD_REQUEST)
.entity("Invalid setting key format: " + htmlEscape(key))
.build();
}
Object value = getSettingByKey(key);
if (value == null) {
return ResponseEntity.badRequest()
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
return Response.status(Response.Status.BAD_REQUEST)
.entity("Setting key not found: " + htmlEscape(key))
.build();
}
// Mask sensitive values before returning
@@ -419,19 +440,22 @@ public class AdminSettingsController {
}
log.debug("Admin requested setting: {}", key);
return ResponseEntity.ok(new SettingValueResponse(key, value));
return Response.ok(new SettingValueResponse(key, value)).build();
} catch (IllegalArgumentException e) {
log.error("Invalid setting key {}: {}", key, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Invalid setting key: " + HtmlUtils.htmlEscape(key));
return Response.status(Response.Status.BAD_REQUEST)
.entity("Invalid setting key: " + htmlEscape(key))
.build();
} catch (Exception e) {
log.error("Error retrieving setting {}: {}", key, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to retrieve setting.");
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Failed to retrieve setting.")
.build();
}
}
@PutMapping("/key/{key}")
@PUT
@jakarta.ws.rs.Path("/key/{key}")
@Operation(
summary = "Update specific setting value",
description =
@@ -446,12 +470,13 @@ public class AdminSettingsController {
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to save setting")
})
public ResponseEntity<String> updateSettingValue(
@PathVariable String key, @Valid @RequestBody UpdateSettingValueRequest request) {
public Response updateSettingValue(
@PathParam("key") String key, @Valid UpdateSettingValueRequest request) {
try {
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
return Response.status(Response.Status.BAD_REQUEST)
.entity("Invalid setting key format: " + htmlEscape(key))
.build();
}
Object value = request.getValue();
@@ -463,9 +488,10 @@ public class AdminSettingsController {
log.warn(
"Admin attempted to save masked value for sensitive field: {}. This operation is blocked to prevent data loss.",
key);
return ResponseEntity.badRequest()
.body(
"Cannot save masked values for sensitive settings. Please provide the actual value.");
return Response.status(Response.Status.BAD_REQUEST)
.entity(
"Cannot save masked values for sensitive settings. Please provide the actual value.")
.build();
}
}
@@ -475,31 +501,38 @@ public class AdminSettingsController {
// Track this as a pending change
pendingChanges.put(key, value);
String escapedKey = HtmlUtils.htmlEscape(key);
return ResponseEntity.ok(
String.format(
"Successfully updated setting '%s'. Changes will take effect on"
+ " application restart.",
escapedKey));
String escapedKey = htmlEscape(key);
return Response.ok(
String.format(
"Successfully updated setting '%s'. Changes will take effect on"
+ " application restart.",
escapedKey))
.build();
} catch (IOException e) {
log.error("Failed to save setting to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(GENERIC_FILE_ERROR)
.build();
} catch (IllegalArgumentException e) {
log.error("Invalid setting key or value: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
return Response.status(Response.Status.BAD_REQUEST)
.entity(GENERIC_INVALID_SETTING)
.build();
} catch (Exception e) {
log.error("Unexpected error while updating setting: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(GENERIC_SERVER_ERROR)
.build();
}
}
@PostMapping("/restart")
@POST
@jakarta.ws.rs.Path("/restart")
@Operation(
summary = "Restart the application",
description =
"Triggers a graceful restart of the Spring Boot application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.")
"Triggers a graceful restart of the application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Restart initiated successfully"),
@@ -508,7 +541,7 @@ public class AdminSettingsController {
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to initiate restart")
})
public ResponseEntity<Map<String, Object>> restartApplication() {
public Response restartApplication() {
try {
log.warn("Admin initiated application restart");
@@ -518,20 +551,22 @@ public class AdminSettingsController {
if (appJar == null) {
log.error("Cannot restart: not running from JAR (likely development mode)");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(
return Response.status(Response.Status.SERVICE_UNAVAILABLE)
.entity(
Map.of(
"error",
"Restart not available in development mode. Please restart the application manually."));
"Restart not available in development mode. Please restart the application manually."))
.build();
}
if (helperJar == null || !Files.isRegularFile(helperJar)) {
log.error("Cannot restart: restart-helper.jar not found at expected location");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(
return Response.status(Response.Status.SERVICE_UNAVAILABLE)
.entity(
Map.of(
"error",
"Restart helper not found. Cannot perform application restart."));
"Restart helper not found. Cannot perform application restart."))
.build();
}
// Get current application arguments
@@ -577,26 +612,29 @@ public class AdminSettingsController {
try {
Thread.sleep(1000);
log.info("Shutting down for restart...");
SpringApplication.exit(applicationContext, () -> 0);
System.exit(0);
// Trigger a graceful Quarkus shutdown (fires ShutdownEvent /
// @PreDestroy); equivalent to SpringApplication.exit(context).
Quarkus.asyncExit(0);
} catch (InterruptedException e) {
log.error("Restart interrupted: {}", e.getMessage(), e);
Thread.currentThread().interrupt();
}
});
return ResponseEntity.ok(
Map.of(
"message",
"Application restart initiated. The server will be back online shortly."));
return Response.ok(
Map.of(
"message",
"Application restart initiated. The server will be back online shortly."))
.build();
} catch (Exception e) {
log.error("Failed to initiate restart: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(
Map.of(
"error",
"Failed to initiate application restart: " + e.getMessage()));
"Failed to initiate application restart: " + e.getMessage()))
.build();
}
}
@@ -953,4 +991,26 @@ public class AdminSettingsController {
// Set the final value
current.put(parts[parts.length - 1], value);
}
// Replacement for Spring's org.springframework.web.util.HtmlUtils.htmlEscape (no
// Quarkus/Jakarta equivalent and commons-text is not a dependency). Mirrors the subset of
// behavior required to escape user-supplied keys/section names echoed into error messages.
private static String htmlEscape(String input) {
if (input == null) {
return "";
}
StringBuilder sb = new StringBuilder(input.length());
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
switch (c) {
case '&' -> sb.append("&amp;");
case '<' -> sb.append("&lt;");
case '>' -> sb.append("&gt;");
case '"' -> sb.append("&quot;");
case '\'' -> sb.append("&#39;");
default -> sb.append(c);
}
}
return sb.toString();
}
}
@@ -3,27 +3,31 @@ package stirling.software.proprietary.security.controller.api;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response;
// TODO: Migration required - CustomUserDetailsService (a collaborator not yet migrated) still
// returns org.springframework.security.core.userdetails.UserDetails and throws
// UsernameNotFoundException; UserService.isPasswordCorrect path may surface a Spring
// AuthenticationException. These Spring-security types are kept until those collaborators migrate
// (e.g. to a Quarkus IdentityProvider / plain user-loading service). Remove these imports then.
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
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 io.quarkus.security.identity.SecurityIdentity;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
@@ -47,23 +51,24 @@ import stirling.software.proprietary.security.util.DesktopClientUtils;
import stirling.software.proprietary.service.AiUserDataService;
/** REST API Controller for authentication operations. */
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@ApplicationScoped
@Path("/api/v1/auth")
@Slf4j
@Tag(name = "Authentication", description = "Endpoints for user authentication and registration")
public class AuthController {
private final UserService userService;
private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService;
private final LoginAttemptService loginAttemptService;
private final MfaService mfaService;
private final TotpService totpService;
private final RefreshRateLimitService refreshRateLimitService;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
private final AiUserDataService aiUserDataService;
@Inject UserService userService;
@Inject JwtServiceInterface jwtService;
@Inject CustomUserDetailsService userDetailsService;
@Inject LoginAttemptService loginAttemptService;
@Inject MfaService mfaService;
@Inject TotpService totpService;
@Inject RefreshRateLimitService refreshRateLimitService;
@Inject ApplicationProperties.Security securityProperties;
@Inject ApplicationProperties applicationProperties;
@Inject AiUserDataService aiUserDataService;
@Inject SecurityIdentity securityIdentity;
/**
* Login endpoint - replaces Supabase signInWithPassword
@@ -72,38 +77,45 @@ public class AuthController {
* @param response HTTP response to set JWT cookie
* @return User and session information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/login")
// TODO: Migration required - Spring @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") was a
// negated SpEL authority check with no direct JAX-RS @RolesAllowed equivalent. Enforce the
// "not a demo user" rule via a SecurityIdentity check in-method, a SecurityIdentityAugmentor,
// or a quarkus.http.auth.* policy.
@POST
@Path("/login")
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public ResponseEntity<?> login(
@RequestBody UsernameAndPassMfa request,
HttpServletRequest httpRequest,
HttpServletResponse response) {
public Response login(
UsernameAndPassMfa request,
@Context HttpServletRequest httpRequest,
@Context HttpServletResponse response) {
try {
// Check if username/password authentication is allowed
if (!securityProperties.isUserPass()) {
log.warn(
"Username/password login attempted but not allowed by current login method configuration");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(
return Response.status(Response.Status.FORBIDDEN)
.entity(
Map.of(
"error",
"Username/password authentication is not enabled. Please use the configured authentication method."));
"Username/password authentication is not enabled. Please use the configured authentication method."))
.build();
}
// Validate input parameters
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
log.warn("Login attempt with null or empty username");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Username is required"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Username is required"))
.build();
}
if (request.getPassword() == null || request.getPassword().isEmpty()) {
log.warn(
"Login attempt with null or empty password for user: {}",
request.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Password is required"))
.build();
}
String username = request.getUsername().trim();
@@ -112,8 +124,9 @@ public class AuthController {
// Check if account is blocked due to too many failed attempts
if (loginAttemptService.isBlocked(username)) {
log.warn("Blocked account login attempt for user: {} from IP: {}", username, ip);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Account is locked due to too many failed attempts"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Account is locked due to too many failed attempts"))
.build();
}
log.debug("Login attempt for user: {} from IP: {}", username, ip);
@@ -124,14 +137,16 @@ public class AuthController {
if (!userService.isPasswordCorrect(user, request.getPassword())) {
log.warn("Invalid password for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid username or password"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Invalid username or password"))
.build();
}
if (!user.isEnabled()) {
log.warn("Disabled user attempted login: {} from IP: {}", username, ip);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User account is disabled"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "User account is disabled"))
.build();
}
if (mfaService.isMfaEnabled(user)) {
@@ -142,36 +157,40 @@ public class AuthController {
username,
ip);
// loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(
return Response.status(Response.Status.UNAUTHORIZED)
.entity(
Map.of(
"error", "mfa_required",
"message", "Two-factor code required"));
"message", "Two-factor code required"))
.build();
}
String secret = mfaService.getSecret(user);
if (secret == null || secret.isBlank()) {
log.error("MFA enabled but no secret stored for user: {}", username);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "MFA configuration error"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "MFA configuration error"))
.build();
}
Long timeStep = totpService.getValidTimeStep(secret, code);
if (timeStep == null) {
log.warn("Invalid MFA code for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(
return Response.status(Response.Status.UNAUTHORIZED)
.entity(
Map.of(
"error", "invalid_mfa_code",
"message", "Invalid two-factor code"));
"message", "Invalid two-factor code"))
.build();
}
if (!mfaService.markTotpStepUsed(user, timeStep)) {
log.warn("Replay MFA code detected for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(
return Response.status(Response.Status.UNAUTHORIZED)
.entity(
Map.of(
"error", "invalid_mfa_code",
"message", "Invalid two-factor code"));
"message", "Invalid two-factor code"))
.build();
}
}
@@ -216,32 +235,36 @@ public class AuthController {
ip,
isDesktopClient);
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session",
Map.of(
"access_token",
token,
"expires_in",
getTokenExpirySeconds(isDesktopClient))));
return Response.ok(
Map.of(
"user", buildUserResponse(user),
"session",
Map.of(
"access_token",
token,
"expires_in",
getTokenExpirySeconds(isDesktopClient))))
.build();
} catch (UsernameNotFoundException e) {
String username = request.getUsername();
log.warn("User not found: {}", username);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid username or password"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Invalid username or password"))
.build();
} catch (AuthenticationException e) {
String username = request.getUsername();
log.error("Authentication failed for user: {}", username, e);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Invalid credentials"))
.build();
} catch (Exception e) {
log.error("Login error for user: {}", request.getUsername(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Internal server error"))
.build();
}
}
@@ -250,28 +273,35 @@ public class AuthController {
*
* @return Current authenticated user information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser() {
// TODO: Migration required - Spring @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") negated
// authority check has no direct @RolesAllowed equivalent; enforce via SecurityIdentity/policy.
@GET
@Path("/me")
public Response getCurrentUser() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null
|| !auth.isAuthenticated()
|| "anonymousUser".equals(auth.getPrincipal())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
// TODO: Migration required - was SecurityContextHolder.getContext().getAuthentication().
// Quarkus SecurityIdentity has no Spring UserDetails principal; loading the full User
// here requires a SecurityIdentityAugmentor that attaches the User (or re-loading via
// userDetailsService by name). Until then we re-load the user from the identity name.
if (securityIdentity == null
|| securityIdentity.isAnonymous()
|| securityIdentity.getPrincipal() == null) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Not authenticated"))
.build();
}
UserDetails userDetails = (UserDetails) auth.getPrincipal();
String username = securityIdentity.getPrincipal().getName();
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
User user = (User) userDetails;
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
return Response.ok(Map.of("user", buildUserResponse(user))).build();
} catch (Exception e) {
log.error("Get current user error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Internal server error"))
.build();
}
}
@@ -281,22 +311,28 @@ public class AuthController {
* @param response HTTP response
* @return Success message
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
// TODO: Migration required - Spring @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") negated
// authority check has no direct @RolesAllowed equivalent; enforce via SecurityIdentity/policy.
@POST
@Path("/logout")
public Response logout(
@Context HttpServletRequest request, @Context HttpServletResponse response) {
try {
String username = jwtService.extractUsernameFromRequestAllowExpired(request);
SecurityContextHolder.clearContext();
// TODO: Migration required - SecurityContextHolder.clearContext() has no Quarkus
// equivalent; SecurityIdentity is request-scoped and not cleared imperatively. Cookie/
// token invalidation is handled by the JWT cookie being dropped by the client/filter.
aiUserDataService.purgeUserDocuments(username);
log.debug("User logged out successfully (username={})", username);
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
return Response.ok(Map.of("message", "Logged out successfully")).build();
} catch (Exception e) {
log.error("Logout error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Internal server error"))
.build();
}
}
@@ -307,15 +343,19 @@ public class AuthController {
* @param response HTTP response to set new JWT cookie
* @return New token information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
// TODO: Migration required - Spring @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") negated
// authority check has no direct @RolesAllowed equivalent; enforce via SecurityIdentity/policy.
@POST
@Path("/refresh")
public Response refresh(
@Context HttpServletRequest request, @Context HttpServletResponse response) {
try {
String token = jwtService.extractToken(request);
if (token == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "No token found"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "No token found"))
.build();
}
// Generate token hash for rate limiting (avoid storing actual tokens)
@@ -324,8 +364,9 @@ public class AuthController {
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
if (!isRefreshWithinGrace(claims)) {
log.warn("Token refresh rejected: token expired beyond configured grace window");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Token refresh failed"))
.build();
}
// Only apply rate limiting if token is actually expired (not for valid tokens)
@@ -338,21 +379,24 @@ public class AuthController {
log.warn(
"Token refresh rejected: rate limit exceeded (max {} attempts allowed)",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(
// HTTP 429 TOO_MANY_REQUESTS is not in JAX-RS Response.Status enum; use numeric code
return Response.status(429)
.entity(
Map.of(
"error",
"Too many refresh attempts",
"max_attempts",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE));
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE))
.build();
}
Object usernameClaim = claims.get("sub");
String username = usernameClaim != null ? usernameClaim.toString() : null;
if (username == null || username.isBlank()) {
log.warn("Token refresh rejected: missing subject claim");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Token refresh failed"))
.build();
}
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
@@ -390,48 +434,57 @@ public class AuthController {
log.debug("Token refreshed for user: {}", username);
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session",
Map.of(
"access_token",
newToken,
"expires_in",
getTokenExpirySeconds(isDesktopClient))));
return Response.ok(
Map.of(
"user", buildUserResponse(user),
"session",
Map.of(
"access_token",
newToken,
"expires_in",
getTokenExpirySeconds(isDesktopClient))))
.build();
} catch (AuthenticationFailureException e) {
log.warn("Token refresh failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Token refresh failed"))
.build();
} catch (Exception e) {
log.error("Token refresh error", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Token refresh failed"))
.build();
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@GetMapping("/mfa/setup")
public ResponseEntity<?> setupMfa(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
// TODO: Migration required - Spring @PreAuthorize("isAuthenticated() &&
// !hasAuthority('ROLE_DEMO_USER')") combined an authenticated check with a negated authority.
// The authenticated portion is enforced below via securityIdentity; the "not demo user"
// portion needs a SecurityIdentity check/augmentor or quarkus.http.auth.* policy.
@GET
@Path("/mfa/setup")
public Response setupMfa() {
if (securityIdentity == null || securityIdentity.isAnonymous()) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Not authenticated"))
.build();
}
String username = authentication.getName();
String username = securityIdentity.getPrincipal().getName();
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
Response authTypeResponse = ensureWebAuth(user);
if (authTypeResponse != null) {
return authTypeResponse;
}
if (mfaService.isMfaEnabled(user)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "MFA already enabled"));
return Response.status(Response.Status.CONFLICT)
.entity(Map.of("error", "MFA already enabled"))
.build();
}
try {
@@ -439,147 +492,170 @@ public class AuthController {
mfaService.setSecret(user, secret);
String otpAuthUri = totpService.buildOtpAuthUri(username, secret);
return ResponseEntity.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri));
return Response.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri)).build();
} catch (Exception e) {
log.error("Failed to setup MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to setup MFA"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to setup MFA"))
.build();
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/enable")
public ResponseEntity<?> enableMfa(
@RequestBody MfaCodeRequest request, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
// TODO: Migration required - Spring @PreAuthorize("isAuthenticated() &&
// !hasAuthority('ROLE_DEMO_USER')") - authenticated check enforced via securityIdentity below;
// the "not demo user" portion needs a SecurityIdentity check/augmentor or quarkus.http.auth.*.
@POST
@Path("/mfa/enable")
public Response enableMfa(MfaCodeRequest request) {
if (securityIdentity == null || securityIdentity.isAnonymous()) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Not authenticated"))
.build();
}
String username = authentication.getName();
String username = securityIdentity.getPrincipal().getName();
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
Response authTypeResponse = ensureWebAuth(user);
if (authTypeResponse != null) {
return authTypeResponse;
}
String secret = mfaService.getSecret(user);
if (secret == null || secret.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "MFA setup required"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "MFA setup required"))
.build();
}
if (request == null || request.getCode() == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "MFA code is required"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "MFA code is required"))
.build();
}
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
if (timeStep == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid two-factor code"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Invalid two-factor code"))
.build();
}
try {
if (!mfaService.isTotpStepUsable(user, timeStep)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid two-factor code"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Invalid two-factor code"))
.build();
}
mfaService.enableMfa(user);
mfaService.markTotpStepUsed(user, timeStep);
mfaService.setMfaRequired(user, false);
return ResponseEntity.ok(Map.of("enabled", true));
return Response.ok(Map.of("enabled", true)).build();
} catch (Exception e) {
log.error("Failed to enable MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to enable MFA"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to enable MFA"))
.build();
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/disable")
public ResponseEntity<?> disableMfa(
@RequestBody MfaCodeRequest request, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
// TODO: Migration required - Spring @PreAuthorize("isAuthenticated() &&
// !hasAuthority('ROLE_DEMO_USER')") - authenticated check enforced via securityIdentity below;
// the "not demo user" portion needs a SecurityIdentity check/augmentor or quarkus.http.auth.*.
@POST
@Path("/mfa/disable")
public Response disableMfa(MfaCodeRequest request) {
if (securityIdentity == null || securityIdentity.isAnonymous()) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Not authenticated"))
.build();
}
String username = authentication.getName();
String username = securityIdentity.getPrincipal().getName();
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
Response authTypeResponse = ensureWebAuth(user);
if (authTypeResponse != null) {
return authTypeResponse;
}
if (!mfaService.isMfaEnabled(user)) {
return ResponseEntity.ok(Map.of("enabled", false));
return Response.ok(Map.of("enabled", false)).build();
}
String secret = mfaService.getSecret(user);
if (secret == null || secret.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "MFA configuration missing"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "MFA configuration missing"))
.build();
}
if (request == null || request.getCode() == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "MFA code is required"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "MFA code is required"))
.build();
}
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
if (timeStep == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid two-factor code"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Invalid two-factor code"))
.build();
}
try {
if (!mfaService.isTotpStepUsable(user, timeStep)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid two-factor code"));
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Invalid two-factor code"))
.build();
}
mfaService.disableMfa(user);
mfaService.markTotpStepUsed(user, timeStep);
return ResponseEntity.ok(Map.of("enabled", false));
return Response.ok(Map.of("enabled", false)).build();
} catch (Exception e) {
log.error("Failed to disable MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to disable MFA"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to disable MFA"))
.build();
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/setup/cancel")
public ResponseEntity<?> cancelMfaSetup(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
// TODO: Migration required - Spring @PreAuthorize("isAuthenticated() &&
// !hasAuthority('ROLE_DEMO_USER')") - authenticated check enforced via securityIdentity below;
// the "not demo user" portion needs a SecurityIdentity check/augmentor or quarkus.http.auth.*.
@POST
@Path("/mfa/setup/cancel")
public Response cancelMfaSetup() {
if (securityIdentity == null || securityIdentity.isAnonymous()) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Not authenticated"))
.build();
}
String username = authentication.getName();
String username = securityIdentity.getPrincipal().getName();
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (mfaService.isMfaEnabled(user)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "MFA already enabled"));
return Response.status(Response.Status.CONFLICT)
.entity(Map.of("error", "MFA already enabled"))
.build();
}
try {
mfaService.clearPendingSecret(user);
return ResponseEntity.ok(Map.of("cleared", true));
return Response.ok(Map.of("cleared", true)).build();
} catch (Exception e) {
log.error("Failed to clear MFA setup for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to clear MFA setup"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to clear MFA setup"))
.build();
}
}
@@ -589,9 +665,10 @@ public class AuthController {
* @param username Username of the user to disable MFA for
* @return Response indicating success or failure
*/
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/mfa/disable/admin/{username}")
public ResponseEntity<?> disableMfaByAdmin(@PathVariable String username) {
@RolesAllowed("ADMIN")
@POST
@Path("/mfa/disable/admin/{username}")
public Response disableMfaByAdmin(@PathParam("username") String username) {
try {
User user =
userService
@@ -599,19 +676,21 @@ public class AuthController {
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (!mfaService.isMfaEnabled(user)) {
return ResponseEntity.ok(Map.of("enabled", false));
return Response.ok(Map.of("enabled", false)).build();
}
mfaService.disableMfa(user);
return ResponseEntity.ok(Map.of("enabled", false));
return Response.ok(Map.of("enabled", false)).build();
} catch (UsernameNotFoundException e) {
log.warn("User not found for MFA disable: {}", username);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found"));
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "User not found"))
.build();
} catch (Exception e) {
log.error("Failed to disable MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to disable MFA"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to disable MFA"))
.build();
}
}
@@ -734,10 +813,11 @@ public class AuthController {
}
}
private ResponseEntity<?> ensureWebAuth(User user) {
private Response ensureWebAuth(User user) {
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "MFA settings are only available for web accounts"));
return Response.status(Response.Status.FORBIDDEN)
.entity(Map.of("error", "MFA settings are only available for web accounts"))
.build();
}
return null;
}
@@ -3,34 +3,44 @@ package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.springframework.context.annotation.Conditional;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PathParam;
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.annotations.api.DatabaseApi;
import stirling.software.proprietary.security.database.H2SQLCondition;
import stirling.software.common.model.multipart.FileUploadMultipartFile;
import stirling.software.proprietary.security.service.DatabaseService;
@Slf4j
@ApplicationScoped
@DatabaseApi
@PreAuthorize("hasRole('ADMIN')")
@Conditional(H2SQLCondition.class)
// DatabaseApi carries only @Tag; JAX-RS does not inherit @Path from meta-annotations, so the base
// path must be declared explicitly here.
@jakarta.ws.rs.Path("/api/v1/database")
@RolesAllowed("ADMIN")
// TODO: Migration required - @Conditional(H2SQLCondition.class) gated this controller on the
// datasource being H2 (driver/url inspection of the Spring Environment). Quarkus has no
// @Conditional equivalent; this must be re-expressed either as a build-time @IfBuildProfile, a
// runtime @LookupIfProperty on a datasource property, or a runtime guard inside DatabaseService
// that no-ops/returns 404 when the active datasource is not H2.
@RequiredArgsConstructor
public class DatabaseController {
@@ -39,51 +49,58 @@ public class DatabaseController {
@Operation(
summary = "Import a database backup file",
description = "Uploads and imports a database backup SQL file.")
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "import-database")
public ResponseEntity<?> importDatabase(
@Parameter(description = "SQL file to import", required = true)
@RequestParam("fileInput")
MultipartFile file)
@POST
@jakarta.ws.rs.Path("import-database")
@jakarta.ws.rs.Consumes(MediaType.MULTIPART_FORM_DATA)
public Response importDatabase(
@Parameter(description = "SQL file to import", required = true) @RestForm("fileInput")
FileUpload fileInput)
throws IOException {
stirling.software.common.model.MultipartFile file =
fileInput == null ? null : FileUploadMultipartFile.of(fileInput);
if (file == null || file.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
java.util.Map.of(
"error",
"fileNullOrEmpty",
"message",
"File is null or empty"));
"File is null or empty"))
.build();
}
log.info("Received file: {}", file.getOriginalFilename());
Path tempTemplatePath = Files.createTempFile("backup_", ".sql");
java.nio.file.Path tempTemplatePath = Files.createTempFile("backup_", ".sql");
try (InputStream in = file.getInputStream()) {
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath);
if (importSuccess) {
return ResponseEntity.ok(
java.util.Map.of(
"message",
"importIntoDatabaseSuccessed",
"description",
"Database imported successfully"));
return Response.ok(
java.util.Map.of(
"message",
"importIntoDatabaseSuccessed",
"description",
"Database imported successfully"))
.build();
} else {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database file"));
"Failed to import database file"))
.build();
}
} catch (Exception e) {
log.error("Error importing database: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database: " + e.getMessage()));
"Failed to import database: " + e.getMessage()))
.build();
}
}
@@ -91,18 +108,20 @@ public class DatabaseController {
@Operation(
summary = "Import database backup by filename",
description = "Imports a database backup file from the server using its file name.")
@GetMapping("/import-database-file/{fileName}")
public ResponseEntity<?> importDatabaseFromBackupUI(
@Parameter(description = "Name of the file to import", required = true) @PathVariable
@GET
@jakarta.ws.rs.Path("/import-database-file/{fileName}")
public Response importDatabaseFromBackupUI(
@Parameter(description = "Name of the file to import", required = true) @PathParam("fileName")
String fileName) {
if (fileName == null || fileName.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
java.util.Map.of(
"error",
"fileNullOrEmpty",
"message",
"File name is null or empty"));
"File name is null or empty"))
.build();
}
// Check if the file exists in the backup list
boolean fileExists =
@@ -110,73 +129,80 @@ public class DatabaseController {
.anyMatch(backup -> backup.getFileName().equals(fileName));
if (!fileExists) {
log.error("File {} not found in backup list", fileName);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(
return Response.status(Response.Status.NOT_FOUND)
.entity(
java.util.Map.of(
"error",
"fileNotFound",
"message",
"File not found in backup list"));
"File not found in backup list"))
.build();
}
log.info("Received file: {}", fileName);
if (databaseService.importDatabaseFromUI(fileName)) {
log.info("File {} imported to database", fileName);
return ResponseEntity.ok(
java.util.Map.of(
"message",
"importIntoDatabaseSuccessed",
"description",
"Database backup imported successfully"));
return Response.ok(
java.util.Map.of(
"message",
"importIntoDatabaseSuccessed",
"description",
"Database backup imported successfully"))
.build();
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database file"));
"Failed to import database file"))
.build();
}
@Hidden
@Operation(
summary = "Delete a database backup file",
description = "Deletes a specified database backup file from the server.")
@GetMapping("/delete/{fileName}")
public ResponseEntity<?> deleteFile(
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
@GET
@jakarta.ws.rs.Path("/delete/{fileName}")
public Response deleteFile(
@Parameter(description = "Name of the file to delete", required = true) @PathParam("fileName")
String fileName) {
if (fileName == null || fileName.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
java.util.Map.of(
"error",
"invalidFileName",
"message",
"File must not be null or empty"));
"File must not be null or empty"))
.build();
}
try {
if (databaseService.deleteBackupFile(fileName)) {
log.info("Deleted file: {}", fileName);
return ResponseEntity.ok(java.util.Map.of("message", "File deleted successfully"));
return Response.ok(java.util.Map.of("message", "File deleted successfully")).build();
} else {
log.error("Failed to delete file: {}", fileName);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(
java.util.Map.of(
"error",
"failedToDeleteFile",
"message",
"Failed to delete backup file"));
"Failed to delete backup file"))
.build();
}
} catch (IOException e) {
log.error("Error deleting file: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(
java.util.Map.of(
"error",
"deleteError",
"message",
"Error deleting file: " + e.getMessage()));
"Error deleting file: " + e.getMessage()))
.build();
}
}
@@ -184,9 +210,10 @@ public class DatabaseController {
@Operation(
summary = "Download a database backup file",
description = "Downloads the specified database backup file from the server.")
@GetMapping("/download/{fileName}")
public ResponseEntity<?> downloadFile(
@Parameter(description = "Name of the file to download", required = true) @PathVariable
@GET
@jakarta.ws.rs.Path("/download/{fileName}")
public Response downloadFile(
@Parameter(description = "Name of the file to download", required = true) @PathParam("fileName")
String fileName) {
if (fileName == null || fileName.isEmpty()) {
throw new IllegalArgumentException("File must not be null or empty");
@@ -196,48 +223,58 @@ public class DatabaseController {
// Only allow files matching the backup naming pattern
if (!fileName.startsWith("backup_") || !fileName.endsWith(".sql")) {
log.warn("Attempted download of non-backup file: {}", fileName);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
java.util.Map.of(
"error",
"invalidFileName",
"message",
"Only backup files are allowed"));
"Only backup files are allowed"))
.build();
}
try {
Path filePath = databaseService.getBackupFilePath(fileName);
InputStreamResource resource = new InputStreamResource(Files.newInputStream(filePath));
return ResponseEntity.ok()
java.nio.file.Path filePath = databaseService.getBackupFilePath(fileName);
long contentLength = Files.size(filePath);
StreamingOutput stream =
output -> {
try (InputStream in = Files.newInputStream(filePath)) {
in.transferTo(output);
}
};
return Response.ok(stream)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + fileName)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(Files.size(filePath))
.body(resource);
.type(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_LENGTH, contentLength)
.build();
} catch (IOException e) {
log.error("Error downloading file: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(
java.util.Map.of(
"error",
"downloadFailed",
"message",
"Failed to download file: " + e.getMessage()));
"Failed to download file: " + e.getMessage()))
.build();
}
}
@Operation(
summary = "Create a database backup",
description = "This endpoint triggers the creation of a database backup.")
@GetMapping("/createDatabaseBackup")
public ResponseEntity<?> createDatabaseBackup() {
@GET
@jakarta.ws.rs.Path("/createDatabaseBackup")
public Response createDatabaseBackup() {
log.info("Starting database backup creation...");
databaseService.exportDatabase();
log.info("Database backup successfully created.");
return ResponseEntity.ok(
java.util.Map.of(
"message",
"backupCreated",
"description",
"Database backup created successfully"));
return Response.ok(
java.util.Map.of(
"message",
"backupCreated",
"description",
"Database backup created successfully"))
.build();
}
}
@@ -1,16 +1,18 @@
package stirling.software.proprietary.security.controller.api;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mail.MailSendException;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.mail.MessagingException;
import jakarta.validation.Valid;
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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -18,6 +20,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.multipart.FileUploadMultipartFile;
import stirling.software.proprietary.security.model.api.Email;
import stirling.software.proprietary.security.service.EmailService;
@@ -25,23 +28,34 @@ import stirling.software.proprietary.security.service.EmailService;
* Controller for handling email-related API requests. This controller exposes an endpoint for
* sending emails with attachments.
*/
// TODO: Migration required - Spring @ConditionalOnProperty(mail.enabled) gated bean creation. CDI
// has no direct runtime-toggle equivalent; this controller is always registered and instead guards
// at request time via the injected mail.enabled config below. If the endpoint must be fully absent
// when mail is disabled, wire this with @io.quarkus.arc.lookup.LookupIfProperty or a build-time
// @io.quarkus.arc.profile.IfBuildProfile once a build/runtime decision is made.
@GeneralApi
@Path("/api/v1/general")
@ApplicationScoped
@RequiredArgsConstructor
@Slf4j
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
public class EmailController {
private final EmailService emailService;
@ConfigProperty(name = "mail.enabled", defaultValue = "false")
boolean mailEnabled;
/**
* Endpoint to send an email with an attachment. This method consumes a multipart/form-data
* request containing the email details and attachment.
*
* @param email The Email object containing recipient address, subject, body, and file
* attachment.
* @return ResponseEntity with success or error message.
* @return Response with success or error message.
*/
@POST
@Path("/send-email")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
consumes = MediaType.MULTIPART_FORM_DATA,
value = "/send-email",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@Operation(
@@ -49,23 +63,42 @@ public class EmailController {
description =
"This endpoint sends an email with an attachment. Input:PDF"
+ " Output:Success/Failure Type:MISO")
public ResponseEntity<String> sendEmailWithAttachment(@Valid @ModelAttribute Email email) {
public Response sendEmailWithAttachment(
@RestForm("fileInput") FileUpload fileUpload,
@RestForm("to") String to,
@RestForm("subject") String subject,
@RestForm("body") String body) {
// Rebuild the request model from multipart form fields. Email/GeneralFile are not annotated
// for JAX-RS multipart @BeanParam binding, so we populate them explicitly.
Email email = new Email();
if (fileUpload != null) {
email.setFileInput(FileUploadMultipartFile.of(fileUpload));
}
email.setTo(to);
email.setSubject(subject);
email.setBody(body);
if (!mailEnabled) {
return Response.status(Response.Status.SERVICE_UNAVAILABLE)
.entity("Email sending is disabled")
.build();
}
log.info("Sending email to: {}", email.toString());
try {
// Calls the service to send the email with attachment
emailService.sendEmailWithAttachment(email);
return ResponseEntity.ok("Email sent successfully");
} catch (MailSendException ex) {
// handles your "Invalid Addresses" case
String errorMsg = ex.getMessage();
log.error("MailSendException: {}", errorMsg, ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorMsg);
return Response.ok("Email sent successfully").build();
} catch (MessagingException e) {
// Catches any messaging exception (e.g., invalid email address, SMTP server issues)
// Catches any messaging exception (e.g., invalid email address, SMTP server issues).
// TODO: Migration required - the Spring-specific org.springframework.mail.MailSendException
// ("Invalid Addresses" case) was previously handled separately. Once EmailService is
// migrated off Spring's JavaMailSender that branch can be reintroduced with the
// replacement exception type.
String errorMsg = "Failed to send email: " + e.getMessage();
log.error(errorMsg, e); // Logging the detailed error
// Returns an error response with status 500 (Internal Server Error)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorMsg);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(errorMsg).build();
}
}
}
@@ -5,14 +5,25 @@ import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.resteasy.reactive.RestForm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.InviteApi;
@@ -29,16 +40,17 @@ import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@InviteApi
@jakarta.ws.rs.Path("/api/v1/invite")
@ApplicationScoped
@Slf4j
@RequiredArgsConstructor
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
private final TeamRepository teamRepository;
private final UserService userService;
private final ApplicationProperties applicationProperties;
private final Optional<EmailService> emailService;
private final UserLicenseSettingsService userLicenseSettingsService;
@Inject InviteTokenRepository inviteTokenRepository;
@Inject TeamRepository teamRepository;
@Inject UserService userService;
@Inject ApplicationProperties applicationProperties;
@Inject Instance<EmailService> emailService;
@Inject UserLicenseSettingsService userLicenseSettingsService;
/**
* Generate a new invite link (admin only)
@@ -48,54 +60,66 @@ public class InviteLinkController {
* @param teamId The team to assign (optional, uses default team if not provided)
* @param expiryHours Custom expiry hours (optional, uses default from config)
* @param sendEmail Whether to send the invite link via email (default: false)
* @param principal The authenticated admin user
* @param request The HTTP request
* @return ResponseEntity with the invite link or error
* @param securityContext The authenticated admin user
* @param uriInfo The request URI info
* @return Response with the invite link or error
*/
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/generate")
public ResponseEntity<?> generateInviteLink(
@RequestParam(name = "email", required = false) String email,
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
@RequestParam(name = "teamId", required = false) Long teamId,
@RequestParam(name = "expiryHours", required = false) Integer expiryHours,
@RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail,
@RequestParam(name = "frontendBaseUrl", required = false) String frontendBaseUrl,
Principal principal,
HttpServletRequest request) {
@RolesAllowed("ADMIN")
@POST
@jakarta.ws.rs.Path("/generate")
public Response generateInviteLink(
@RestForm("email") String email,
@RestForm("role") String role,
@RestForm("teamId") Long teamId,
@RestForm("expiryHours") Integer expiryHours,
@RestForm("sendEmail") Boolean sendEmail,
@RestForm("frontendBaseUrl") String frontendBaseUrl,
@Context SecurityContext securityContext,
@Context UriInfo uriInfo) {
// @RequestParam defaults applied manually (JAX-RS @RestForm has no defaultValue)
if (role == null) {
role = "ROLE_USER";
}
boolean sendEmailFlag = sendEmail != null && sendEmail;
Principal principal = securityContext.getUserPrincipal();
try {
// Check if email invites are enabled
if (!applicationProperties.getMail().isEnableInvites()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email invites are not enabled"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Email invites are not enabled"))
.build();
}
// If email is provided, validate and check for conflicts
if (email != null && !email.trim().isEmpty()) {
// Validate email format
if (!email.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid email address"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Invalid email address"))
.build();
}
email = email.trim().toLowerCase();
// Check if user already exists
if (userService.usernameExistsIgnoreCase(email)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "User already exists"));
return Response.status(Response.Status.CONFLICT)
.entity(Map.of("error", "User already exists"))
.build();
}
// Check if there's already an active invite for this email
Optional<InviteToken> existingInvite = inviteTokenRepository.findByEmail(email);
if (existingInvite.isPresent() && existingInvite.get().isValid()) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(
return Response.status(Response.Status.CONFLICT)
.entity(
Map.of(
"error",
"An active invite already exists for this email"
+ " address"));
+ " address"))
.build();
}
} else {
@@ -103,9 +127,10 @@ public class InviteLinkController {
email = null; // Ensure it's null, not empty string
// Cannot send email if no email address provided
if (sendEmail) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot send email without an email address"));
if (sendEmailFlag) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Cannot send email without an email address"))
.build();
}
}
@@ -116,8 +141,8 @@ public class InviteLinkController {
int maxUsers = userLicenseSettingsService.calculateMaxAllowedUsers();
if (currentUserCount + activeInvites >= maxUsers) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"error",
"License limit reached ("
@@ -125,7 +150,8 @@ public class InviteLinkController {
+ "/"
+ maxUsers
+ " users). Contact your administrator to"
+ " upgrade your license."));
+ " upgrade your license."))
.build();
}
}
@@ -133,12 +159,14 @@ public class InviteLinkController {
try {
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Cannot assign INTERNAL_API_USER role"))
.build();
}
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid role specified"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Invalid role specified"))
.build();
}
// Determine team
@@ -153,8 +181,9 @@ public class InviteLinkController {
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
if (selectedTeam != null
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign users to Internal team"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Cannot assign users to Internal team"))
.build();
}
}
@@ -191,13 +220,14 @@ public class InviteLinkController {
} else if (configuredBackendUrl != null && !configuredBackendUrl.trim().isEmpty()) {
baseUrl = configuredBackendUrl.trim();
} else {
// Derive from the incoming request via JAX-RS UriInfo
java.net.URI requestUri = uriInfo.getRequestUri();
int port = requestUri.getPort();
baseUrl =
request.getScheme()
requestUri.getScheme()
+ "://"
+ request.getServerName()
+ (request.getServerPort() != 80 && request.getServerPort() != 443
? ":" + request.getServerPort()
: "");
+ requestUri.getHost()
+ (port != -1 && port != 80 && port != 443 ? ":" + port : "");
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
@@ -209,8 +239,8 @@ public class InviteLinkController {
// Optionally send email
boolean emailSent = false;
String emailError = null;
if (sendEmail) {
if (!emailService.isPresent()) {
if (sendEmailFlag) {
if (!emailService.isResolvable()) {
emailError = "Email service is not configured";
log.warn("Cannot send invite email: Email service not configured");
} else {
@@ -236,19 +266,20 @@ public class InviteLinkController {
response.put("email", email);
response.put("expiresAt", expiresAt.toString());
response.put("expiryHours", effectiveExpiryHours);
if (sendEmail) {
if (sendEmailFlag) {
response.put("emailSent", emailSent);
if (emailError != null) {
response.put("emailError", emailError);
}
}
return ResponseEntity.ok(response);
return Response.ok(response, MediaType.APPLICATION_JSON).build();
} catch (Exception e) {
log.error("Failed to generate invite link: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to generate invite link: " + e.getMessage()));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to generate invite link: " + e.getMessage()))
.build();
}
}
@@ -257,9 +288,10 @@ public class InviteLinkController {
*
* @return List of active invite tokens
*/
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/list")
public ResponseEntity<?> listInviteLinks() {
@RolesAllowed("ADMIN")
@GET
@jakarta.ws.rs.Path("/list")
public Response listInviteLinks() {
try {
List<InviteToken> activeInvites =
inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now());
@@ -282,12 +314,13 @@ public class InviteLinkController {
})
.collect(Collectors.toList());
return ResponseEntity.ok(Map.of("invites", inviteList));
return Response.ok(Map.of("invites", inviteList), MediaType.APPLICATION_JSON).build();
} catch (Exception e) {
log.error("Failed to list invite links: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to list invite links"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to list invite links"))
.build();
}
}
@@ -297,25 +330,30 @@ public class InviteLinkController {
* @param inviteId The invite token ID to revoke
* @return Success or error response
*/
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/revoke/{inviteId}")
public ResponseEntity<?> revokeInviteLink(@PathVariable Long inviteId) {
@RolesAllowed("ADMIN")
@DELETE
@jakarta.ws.rs.Path("/revoke/{inviteId}")
public Response revokeInviteLink(@PathParam("inviteId") Long inviteId) {
try {
Optional<InviteToken> inviteOpt = inviteTokenRepository.findById(inviteId);
if (inviteOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invite not found"));
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Invite not found"))
.build();
}
inviteTokenRepository.deleteById(inviteId);
log.info("Revoked invite link ID: {}", inviteId);
return ResponseEntity.ok(Map.of("message", "Invite link revoked successfully"));
return Response.ok(Map.of("message", "Invite link revoked successfully"),
MediaType.APPLICATION_JSON)
.build();
} catch (Exception e) {
log.error("Failed to revoke invite link: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to revoke invite link"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to revoke invite link"))
.build();
}
}
@@ -324,9 +362,10 @@ public class InviteLinkController {
*
* @return Number of deleted tokens
*/
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/cleanup")
public ResponseEntity<?> cleanupExpiredInvites() {
@RolesAllowed("ADMIN")
@POST
@jakarta.ws.rs.Path("/cleanup")
public Response cleanupExpiredInvites() {
try {
List<InviteToken> expiredInvites =
inviteTokenRepository.findAll().stream()
@@ -338,12 +377,13 @@ public class InviteLinkController {
log.info("Cleaned up {} expired invite tokens", count);
return ResponseEntity.ok(Map.of("deletedCount", count));
return Response.ok(Map.of("deletedCount", count), MediaType.APPLICATION_JSON).build();
} catch (Exception e) {
log.error("Failed to cleanup expired invites: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to cleanup expired invites"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to cleanup expired invites"))
.build();
}
}
@@ -353,8 +393,9 @@ public class InviteLinkController {
* @param token The invite token to validate
* @return Invite details if valid, error otherwise
*/
@GetMapping("/validate/{token}")
public ResponseEntity<?> validateInviteToken(@PathVariable String token) {
@GET
@jakarta.ws.rs.Path("/validate/{token}")
public Response validateInviteToken(@PathParam("token") String token) {
try {
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
@@ -384,7 +425,7 @@ public class InviteLinkController {
response.put("expiresAt", invite.getExpiresAt().toString());
response.put("emailRequired", invite.getEmail() == null);
return ResponseEntity.ok(response);
return Response.ok(response, MediaType.APPLICATION_JSON).build();
} catch (Exception e) {
log.error("Failed to validate invite token: {}", e.getMessage(), e);
@@ -400,16 +441,18 @@ public class InviteLinkController {
* @param password The password to set for the new account
* @return Success or error response
*/
@PostMapping("/accept/{token}")
public ResponseEntity<?> acceptInvite(
@PathVariable String token,
@RequestParam(name = "email", required = false) String email,
@RequestParam(name = "password") String password) {
@POST
@jakarta.ws.rs.Path("/accept/{token}")
public Response acceptInvite(
@PathParam("token") String token,
@RestForm("email") String email,
@RestForm("password") String password) {
try {
// Validate password
if (password == null || password.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Password is required"))
.build();
}
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
@@ -433,14 +476,16 @@ public class InviteLinkController {
if (effectiveEmail == null) {
// Email not pre-set, must be provided by user
if (email == null || email.trim().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email address is required"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Email address is required"))
.build();
}
// Validate email format
if (!email.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid email address"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Invalid email address"))
.build();
}
effectiveEmail = email.trim().toLowerCase();
@@ -470,18 +515,26 @@ public class InviteLinkController {
effectiveEmail,
invite.getRole());
return ResponseEntity.ok(
Map.of("message", "Account created successfully", "username", effectiveEmail));
return Response.ok(
Map.of(
"message",
"Account created successfully",
"username",
effectiveEmail),
MediaType.APPLICATION_JSON)
.build();
} catch (Exception e) {
log.error("Failed to accept invite: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to create account"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to create account"))
.build();
}
}
private ResponseEntity<Map<String, String>> invalidInviteResponse() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
private Response invalidInviteResponse() {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Invalid invite link"))
.build();
}
}
@@ -1,144 +1,169 @@
package stirling.software.proprietary.security.controller.api;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.DELETE;
import jakarta.ws.rs.GET;
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.annotations.api.AdminServerCertificateApi;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.multipart.FileUploadMultipartFile;
import stirling.software.common.service.ServerCertificateServiceInterface;
@RestController
@RequestMapping("/api/v1/admin/server-certificate")
@AdminServerCertificateApi
@Path("/api/v1/admin/server-certificate")
@ApplicationScoped
@Slf4j
@Tag(
name = "Admin - Server Certificate",
description = "Admin APIs for server certificate management")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@RolesAllowed("ADMIN")
public class ServerCertificateController {
private final ServerCertificateServiceInterface serverCertificateService;
@GetMapping("/info")
@GET
@Path("/info")
@Operation(
summary = "Get server certificate information",
description = "Returns information about the current server certificate")
public ResponseEntity<ServerCertificateServiceInterface.ServerCertificateInfo>
getServerCertificateInfo() {
public Response getServerCertificateInfo() {
try {
ServerCertificateServiceInterface.ServerCertificateInfo info =
serverCertificateService.getServerCertificateInfo();
return ResponseEntity.ok(info);
return Response.ok(info).build();
} catch (Exception e) {
log.error("Failed to get server certificate info", e);
return ResponseEntity.internalServerError().build();
return Response.serverError().build();
}
}
@PostMapping("/upload")
@POST
@Path("/upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "Upload server certificate",
description =
"Upload a new PKCS12 certificate file to be used as the server certificate")
public ResponseEntity<String> uploadServerCertificate(
public Response uploadServerCertificate(
@Parameter(description = "PKCS12 certificate file", required = true)
@RequestParam("file")
MultipartFile file,
@RestForm("file")
FileUpload fileUpload,
@Parameter(description = "Certificate password", required = true)
@RequestParam("password")
@RestForm("password")
String password) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("Certificate file cannot be empty");
MultipartFile file = FileUploadMultipartFile.of(fileUpload);
if (file == null || file.isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("Certificate file cannot be empty")
.build();
}
if (!file.getOriginalFilename().toLowerCase().endsWith(".p12")
&& !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) {
return ResponseEntity.badRequest()
.body("Only PKCS12 (.p12 or .pfx) files are supported");
return Response.status(Response.Status.BAD_REQUEST)
.entity("Only PKCS12 (.p12 or .pfx) files are supported")
.build();
}
try {
serverCertificateService.uploadServerCertificate(file.getInputStream(), password);
return ResponseEntity.ok("Server certificate uploaded successfully");
return Response.ok("Server certificate uploaded successfully").build();
} catch (IllegalArgumentException e) {
log.warn("Invalid certificate upload: {}", e.getMessage());
return ResponseEntity.badRequest().body("Invalid certificate or password.");
return Response.status(Response.Status.BAD_REQUEST)
.entity("Invalid certificate or password.")
.build();
} catch (Exception e) {
log.error("Failed to upload server certificate", e);
return ResponseEntity.internalServerError().body("Failed to upload server certificate");
return Response.serverError()
.entity("Failed to upload server certificate")
.build();
}
}
@DeleteMapping
@DELETE
@Operation(
summary = "Delete server certificate",
description = "Delete the current server certificate")
public ResponseEntity<String> deleteServerCertificate() {
public Response deleteServerCertificate() {
try {
serverCertificateService.deleteServerCertificate();
return ResponseEntity.ok("Server certificate deleted successfully");
return Response.ok("Server certificate deleted successfully").build();
} catch (Exception e) {
log.error("Failed to delete server certificate", e);
return ResponseEntity.internalServerError().body("Failed to delete server certificate");
return Response.serverError()
.entity("Failed to delete server certificate")
.build();
}
}
@PostMapping("/generate")
@POST
@Path("/generate")
@Operation(
summary = "Generate new server certificate",
description = "Generate a new self-signed server certificate")
public ResponseEntity<String> generateServerCertificate() {
public Response generateServerCertificate() {
try {
serverCertificateService.deleteServerCertificate(); // Remove existing if any
serverCertificateService.initializeServerCertificate(); // Generate new
return ResponseEntity.ok("New server certificate generated successfully");
return Response.ok("New server certificate generated successfully").build();
} catch (Exception e) {
log.error("Failed to generate server certificate", e);
return ResponseEntity.internalServerError()
.body("Failed to generate server certificate");
return Response.serverError()
.entity("Failed to generate server certificate")
.build();
}
}
@GetMapping("/certificate")
@GET
@Path("/certificate")
@Operation(
summary = "Download server certificate",
description = "Download the server certificate in DER format for validation purposes")
public ResponseEntity<byte[]> getServerCertificate() {
public Response getServerCertificate() {
try {
if (!serverCertificateService.hasServerCertificate()) {
return ResponseEntity.notFound().build();
return Response.status(Response.Status.NOT_FOUND).build();
}
byte[] certificate = serverCertificateService.getServerCertificatePublicKey();
return ResponseEntity.ok()
return Response.ok(certificate, MediaType.valueOf("application/pkix-cert"))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"server-cert.cer\"")
.contentType(MediaType.valueOf("application/pkix-cert"))
.body(certificate);
.build();
} catch (Exception e) {
log.error("Failed to get server certificate", e);
return ResponseEntity.internalServerError().build();
return Response.serverError().build();
}
}
@GetMapping("/enabled")
@GET
@Path("/enabled")
@Operation(
summary = "Check if server certificate feature is enabled",
description =
"Returns whether the server certificate feature is enabled in configuration")
public ResponseEntity<Boolean> isServerCertificateEnabled() {
return ResponseEntity.ok(serverCertificateService.isEnabled());
public Response isServerCertificateEnabled() {
return Response.ok(serverCertificateService.isEnabled()).build();
}
}
@@ -3,12 +3,15 @@ package stirling.software.proprietary.security.controller.api;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -22,6 +25,8 @@ import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
@TeamApi
@Path("/api/v1/team")
@ApplicationScoped
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@@ -30,115 +35,140 @@ public class TeamController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/create")
public ResponseEntity<?> createTeam(@RequestParam("name") String name) {
@RolesAllowed("ADMIN")
@POST
@Path("/create")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response createTeam(@QueryParam("name") String name) {
if (teamRepository.existsByNameIgnoreCase(name)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Team name already exists."));
return Response.status(Response.Status.CONFLICT)
.entity(Map.of("error", "Team name already exists."))
.build();
}
Team team = new Team();
team.setName(name);
teamRepository.save(team);
return ResponseEntity.ok(Map.of("message", "Team created successfully"));
return Response.ok(Map.of("message", "Team created successfully")).build();
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/rename")
public ResponseEntity<?> renameTeam(
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
@RolesAllowed("ADMIN")
@POST
@Path("/rename")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response renameTeam(
@QueryParam("teamId") Long teamId, @QueryParam("newName") String newName) {
Optional<Team> existing = teamRepository.findById(teamId);
if (existing.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Team not found."))
.build();
}
if (teamRepository.existsByNameIgnoreCase(newName)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Team name already exists."));
return Response.status(Response.Status.CONFLICT)
.entity(Map.of("error", "Team name already exists."))
.build();
}
Team team = existing.get();
// Prevent renaming the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot rename Internal team."));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Cannot rename Internal team."))
.build();
}
team.setName(newName);
teamRepository.save(team);
return ResponseEntity.ok(Map.of("message", "Team renamed successfully"));
return Response.ok(Map.of("message", "Team renamed successfully")).build();
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/delete")
@RolesAllowed("ADMIN")
@POST
@Path("/delete")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Transactional
public ResponseEntity<?> deleteTeam(@RequestParam("teamId") Long teamId) {
public Response deleteTeam(@QueryParam("teamId") Long teamId) {
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Team not found."))
.build();
}
Team team = teamOpt.get();
// Prevent deleting the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot delete Internal team."));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Cannot delete Internal team."))
.build();
}
long memberCount = userRepository.countByTeam(team);
if (memberCount > 0) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
return Response.status(Response.Status.BAD_REQUEST)
.entity(
Map.of(
"error",
"Team must be empty before deletion. Please remove all members first."));
"Team must be empty before deletion. Please remove all members first."))
.build();
}
teamRepository.delete(team);
return ResponseEntity.ok(Map.of("message", "Team deleted successfully"));
return Response.ok(Map.of("message", "Team deleted successfully")).build();
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/addUser")
@RolesAllowed("ADMIN")
@POST
@Path("/addUser")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Transactional
public ResponseEntity<?> addUserToTeam(
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
public Response addUserToTeam(
@QueryParam("teamId") Long teamId, @QueryParam("userId") Long userId) {
// Find the team
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Team not found."))
.build();
}
Team team = teamOpt.get();
// Prevent adding users to the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot add users to Internal team."));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Cannot add users to Internal team."))
.build();
}
// Find the user
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "User not found."))
.build();
}
User user = userOpt.get();
// Check if user is in the Internal team - prevent moving them
if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot move users from Internal team."));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Cannot move users from Internal team."))
.build();
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
return ResponseEntity.ok(Map.of("message", "User added to team successfully"));
return Response.ok(Map.of("message", "User added to team successfully")).build();
}
// TODO: Migration required - teamRepository/userRepository still extend Spring Data
// JpaRepository. Once they are migrated to Panache, findById(...) returns the entity
// directly (not Optional); update the Optional handling above accordingly. Likewise
// save(...) -> persist(...), delete(...) -> delete(...)/deleteById(...). Derived finders
// existsByNameIgnoreCase / countByTeam must be reimplemented as Panache default methods.
}
@@ -11,18 +11,15 @@ import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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.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.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.core.Response;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -33,8 +30,8 @@ import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@RestController
@RequestMapping("/api/v1/ui-data")
@ApplicationScoped
@jakarta.ws.rs.Path("/api/v1/ui-data")
@RequiredArgsConstructor
@Tag(name = "UI Data")
public class UIDataTessdataController {
@@ -46,25 +43,27 @@ public class UIDataTessdataController {
private static volatile long cachedRemoteTessdataExpiry = 0L;
private static final long REMOTE_TESSDATA_TTL_MS = 10 * 60 * 1000; // 10 minutes
@GetMapping("/tessdata-languages")
@PreAuthorize("hasRole('ADMIN')")
@GET
@jakarta.ws.rs.Path("/tessdata-languages")
@RolesAllowed("ADMIN")
@Operation(summary = "List installed and remotely available tessdata languages")
public ResponseEntity<TessdataLanguagesResponse> getTessdataLanguages() {
public Response getTessdataLanguages() {
TessdataLanguagesResponse response = new TessdataLanguagesResponse();
response.setInstalled(getAvailableTesseractLanguages());
response.setAvailable(getRemoteTessdataLanguages());
response.setWritable(isWritableDirectory(Paths.get(runtimePathConfig.getTessDataPath())));
return ResponseEntity.ok(response);
return Response.ok(response).build();
}
@PostMapping("/tessdata/download")
@PreAuthorize("hasRole('ADMIN')")
@POST
@jakarta.ws.rs.Path("/tessdata/download")
@RolesAllowed("ADMIN")
@Operation(summary = "Download selected tessdata languages from the official repository")
public ResponseEntity<Map<String, Object>> downloadTessdataLanguages(
@RequestBody TessdataDownloadRequest request) {
public Response downloadTessdataLanguages(TessdataDownloadRequest request) {
if (request.getLanguages() == null || request.getLanguages().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "No languages provided for download"));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", "No languages provided for download"))
.build();
}
Path tessdataDir = Paths.get(runtimePathConfig.getTessDataPath());
@@ -72,13 +71,15 @@ public class UIDataTessdataController {
Files.createDirectories(tessdataDir);
} catch (IOException e) {
log.error("Failed to create tessdata directory {}", tessdataDir, e);
return ResponseEntity.internalServerError()
.body(Map.of("message", "Failed to prepare tessdata directory"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("message", "Failed to prepare tessdata directory"))
.build();
}
if (!isWritableDirectory(tessdataDir)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", tessdataDir.toString()));
return Response.status(Response.Status.FORBIDDEN)
.entity(Map.of("message", tessdataDir.toString()))
.build();
}
List<String> downloaded = new ArrayList<>();
@@ -139,11 +140,11 @@ public class UIDataTessdataController {
"tessdataDir", tessdataDir.toString());
if (!downloaded.isEmpty() && failed.isEmpty()) {
return ResponseEntity.ok(response);
return Response.ok(response).build();
} else if (!downloaded.isEmpty()) {
return ResponseEntity.status(207).body(response); // Multi-Status for partial success
return Response.status(207).entity(response).build(); // Multi-Status for partial success
} else {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(response);
return Response.status(Response.Status.BAD_GATEWAY).entity(response).build();
}
}

Some files were not shown because too many files have changed in this diff Show More