Convert proprietary module (security/JPA/oauth2/saml2) to Quarkus via workflow (WIP)
This commit is contained in:
+73
-32
@@ -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);
|
||||
|
||||
+33
-15
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+116
-86
@@ -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 -> 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;
|
||||
}
|
||||
|
||||
|
||||
+24
-11
@@ -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"
|
||||
|
||||
+9
-4
@@ -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;
|
||||
|
||||
+57
-40
@@ -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).
|
||||
|
||||
+38
-10
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -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 {}
|
||||
|
||||
+29
-14
@@ -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());
|
||||
|
||||
+43
-11
@@ -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);
|
||||
}
|
||||
|
||||
+30
-5
@@ -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;
|
||||
|
||||
+36
-36
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
+110
-101
@@ -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,
|
||||
|
||||
+27
-21
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -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;
|
||||
}
|
||||
|
||||
+41
-39
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -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();
|
||||
|
||||
+8
-8
@@ -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 {}
|
||||
|
||||
+34
-29
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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) {
|
||||
|
||||
+29
-25
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+98
-79
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+92
-73
@@ -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;
|
||||
}
|
||||
|
||||
+136
-103
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-15
@@ -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(
|
||||
|
||||
+35
-27
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-33
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+66
-56
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+60
-43
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-11
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+65
-41
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+43
-99
@@ -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);
|
||||
}
|
||||
|
||||
+7
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+9
-8
@@ -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.
|
||||
|
||||
+34
-40
@@ -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) {
|
||||
|
||||
+40
-24
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+18
-11
@@ -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. */
|
||||
|
||||
+12
-5
@@ -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);
|
||||
|
||||
+88
-205
@@ -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.
|
||||
}
|
||||
|
||||
+38
-26
@@ -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();
|
||||
|
||||
+9
-8
@@ -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.");
|
||||
}
|
||||
|
||||
+11
-8
@@ -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");
|
||||
}
|
||||
|
||||
+67
-35
@@ -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)";
|
||||
|
||||
+15
-12
@@ -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();
|
||||
}
|
||||
|
||||
+8
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -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 + "'.");
|
||||
|
||||
+8
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+8
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+10
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -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;
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+3
-4
@@ -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;
|
||||
}
|
||||
|
||||
+13
-6
@@ -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())));
|
||||
|
||||
+167
-95
@@ -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;
|
||||
|
||||
+4
-5
@@ -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.
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+29
-25
@@ -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<?>) {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+2
-3
@@ -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<>();
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
+11
-10
@@ -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
|
||||
|
||||
+5
-5
@@ -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;
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+12
-8
@@ -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())
|
||||
|
||||
+7
-9
@@ -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);
|
||||
|
||||
+2
-3
@@ -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 {
|
||||
|
||||
+9
-10
@@ -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) {
|
||||
|
||||
+8
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -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<>();
|
||||
|
||||
|
||||
+13
-15
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
|
||||
+338
-198
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+65
-26
@@ -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) {
|
||||
|
||||
+54
-17
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+63
-53
@@ -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
-5
@@ -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;
|
||||
|
||||
+17
-11
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-6
@@ -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();
|
||||
}
|
||||
|
||||
+36
-16
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+33
-16
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+24
-23
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+167
-59
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-8
@@ -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();
|
||||
|
||||
+19
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+22
-10
@@ -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).
|
||||
}
|
||||
|
||||
+194
-380
@@ -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,
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
|
||||
+56
-15
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+14
-10
@@ -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();
|
||||
|
||||
+117
-78
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+197
-137
@@ -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("&");
|
||||
case '<' -> sb.append("<");
|
||||
case '>' -> sb.append(">");
|
||||
case '"' -> sb.append(""");
|
||||
case '\'' -> sb.append("'");
|
||||
default -> sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
+277
-197
@@ -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;
|
||||
}
|
||||
|
||||
+125
-88
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+54
-21
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+153
-100
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+74
-49
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+76
-46
@@ -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.
|
||||
}
|
||||
|
||||
+29
-28
@@ -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
Reference in New Issue
Block a user