mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Fix Documents tab in Processor (#7569)
# Description of Changes The Documents tab in the Processor is supposed to be available to all Processor users, but because the API is built on top of the Audit data, which is only for enterprise users, the API call always fails with 403. This means that it never fills the query cache, so every time you go back to the tab it has to reload all the data for a couple of seconds (and will fail again). This fixes the API so that it's available to any Processor user instead of just enterprise users. Also, the documents data was only being written to the log on an enterprise license, so I've changed it so that data is always tracked in the audit log because otherwise the Documents tab would still be useless to non-enterprise users. The Audit Log tab was also available to all Processor users, but would have the same issue where the table would never load because the API would 403 as well. I've just made the Audit Log tab disabled for non-enterprise users now. We might want to do something to signpost it a bit more that it's an enterprise-specific feature, but it's better than nothing for now.
This commit is contained in:
+16
-17
@@ -91,24 +91,32 @@ public class ControllerAuditAspect {
|
||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = sig.getMethod();
|
||||
|
||||
// Fast path: check if auditing is enabled before doing any work
|
||||
// This avoids all data collection if auditing is disabled
|
||||
if (!auditService.shouldAudit(method, auditConfig)) {
|
||||
// Resolve the event type up front so the enterprise gate can be type-aware: document
|
||||
// processing events (the Documents tab's data source) are audited without an Enterprise
|
||||
// license, while the rest of the audit log stays Enterprise-only. resolveEventType is cheap
|
||||
// (annotation / class / path checks), so it's safe on the pre-record fast path.
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Fast path: skip all data collection when this event won't be recorded.
|
||||
if (!auditService.shouldAudit(eventType, method, auditConfig)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// Check if method is explicitly annotated with @Audited
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel level = auditConfig.getAuditLevel();
|
||||
|
||||
// If @Audited annotation is present, respect its level setting
|
||||
if (auditedAnnotation != null) {
|
||||
// Use the level from annotation if it's stricter than global level
|
||||
level = auditedAnnotation.level();
|
||||
}
|
||||
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
|
||||
// Skip static GET resources
|
||||
if ("GET".equals(httpMethod)) {
|
||||
HttpServletRequest maybe = auditService.getCurrentRequest();
|
||||
@@ -209,15 +217,6 @@ public class ControllerAuditAspect {
|
||||
// the body ran, so it must happen here rather than with the pre-proceed HTTP data).
|
||||
auditService.addAutomationContext(data, req);
|
||||
|
||||
// Resolve the event type using the unified method
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Add result only if operation result capture is explicitly enabled
|
||||
// Skip result for UI_DATA events to avoid storing large response bodies
|
||||
if (auditService.shouldCaptureOperationResults()
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Self-hosted default: any portal user sees the whole-server documents queue. */
|
||||
@Component
|
||||
public class DefaultPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
/** Resolves which slice of the documents queue a portal user may see. */
|
||||
public interface PortalDocumentsScopeResolver {
|
||||
|
||||
PortalAuditScope resolve();
|
||||
}
|
||||
+14
-6
@@ -2,6 +2,7 @@ package stirling.software.proprietary.controller.api;
|
||||
|
||||
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.RequestParam;
|
||||
|
||||
@@ -11,19 +12,25 @@ import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.service.PortalDocumentsService;
|
||||
|
||||
/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */
|
||||
/**
|
||||
* Serves the portal Documents review queue, derived from real audit data and scoped per caller.
|
||||
*
|
||||
* <p>Open to every portal user (not Enterprise-gated): the Documents tab is a core Processor
|
||||
* feature. Access is enforced by {@code @resourceAccess.canUsePortal()}; visibility is then
|
||||
* resolved per deployment - self-hosted portal users see the whole server, SaaS users see their
|
||||
* team (see {@link PortalDocumentsScopeResolver}).
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
@PreAuthorize("@resourceAccess.canUsePortal()")
|
||||
public class PortalDocumentsController {
|
||||
|
||||
private final PortalDocumentsService portalDocumentsService;
|
||||
private final PortalAuditScopeResolver auditScopeResolver;
|
||||
private final PortalDocumentsScopeResolver documentsScopeResolver;
|
||||
|
||||
// tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped).
|
||||
@GetMapping("/documents")
|
||||
@@ -32,8 +39,9 @@ public class PortalDocumentsController {
|
||||
description = "Files processed through the org, derived from the audit trail.")
|
||||
public ResponseEntity<PortalDocumentsResponseDto> getDocuments(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
PortalAuditScope scope = auditScopeResolver.resolve();
|
||||
PortalAuditScope scope = documentsScopeResolver.resolve();
|
||||
if (!scope.allowed()) {
|
||||
// SaaS caller with no team has nothing to show; surface an empty tab, not a 500.
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
PortalDocumentsResponseDto body =
|
||||
|
||||
+33
-3
@@ -5,13 +5,13 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
@@ -20,15 +20,31 @@ import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
/** Service to periodically clean up old audit events based on retention policy. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditCleanupService {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final boolean runningEE;
|
||||
|
||||
// Default batch size for deletions
|
||||
private static final int BATCH_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* Maximum audit retention on non-Enterprise instances. Audit events feed the Documents tab on
|
||||
* every instance, but longer history is an Enterprise feature - so non-EE deployments keep a
|
||||
* shorter window ("infinite" included), bounding the always-on trail off-license.
|
||||
*/
|
||||
private static final int NON_EE_MAX_RETENTION_DAYS = 30;
|
||||
|
||||
public AuditCleanupService(
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
AuditConfigurationProperties auditConfig,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
this.auditRepository = auditRepository;
|
||||
this.auditConfig = auditConfig;
|
||||
this.runningEE = runningEE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled task that runs daily to clean up old audit events. The retention period is
|
||||
* configurable in settings.yml.
|
||||
@@ -39,7 +55,7 @@ public class AuditCleanupService {
|
||||
return;
|
||||
}
|
||||
|
||||
int retentionDays = auditConfig.getRetentionDays();
|
||||
int retentionDays = effectiveRetentionDays();
|
||||
if (retentionDays <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -58,6 +74,20 @@ public class AuditCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention window actually applied. Enterprise uses the configured value (0 = infinite);
|
||||
* non-Enterprise is clamped to {@link #NON_EE_MAX_RETENTION_DAYS}.
|
||||
*/
|
||||
int effectiveRetentionDays() {
|
||||
int configured = auditConfig.getRetentionDays();
|
||||
if (runningEE) {
|
||||
return configured;
|
||||
}
|
||||
return configured <= 0
|
||||
? NON_EE_MAX_RETENTION_DAYS
|
||||
: Math.min(configured, NON_EE_MAX_RETENTION_DAYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs batch deletion of events to prevent long-running transactions and potential database
|
||||
* locks.
|
||||
|
||||
+55
-16
@@ -87,10 +87,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,8 +123,7 @@ public class AuditService {
|
||||
*/
|
||||
public void audit(
|
||||
String principal, AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,8 +152,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,8 +187,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String principal, String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,9 +217,7 @@ public class AuditService {
|
||||
AuditEventType type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,9 +242,7 @@ public class AuditService {
|
||||
String type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,6 +616,55 @@ public class AuditService {
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-aware variant used by the controller aspect, which resolves the event type before
|
||||
* deciding whether to record. Document-processing events feed the Documents tab (available to
|
||||
* every Processor user), so they audit without an Enterprise license; the rest of the audit log
|
||||
* stays Enterprise-only.
|
||||
*/
|
||||
public boolean shouldAudit(
|
||||
AuditEventType eventType, Method method, AuditConfigurationProperties auditConfig) {
|
||||
if (!auditConfig.isEnabled() || !isLicensedToRecord(eventType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel requiredLevel =
|
||||
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
||||
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event of this type and level should be persisted: the configured audit level must
|
||||
* include it and the current license must permit recording it.
|
||||
*/
|
||||
private boolean shouldRecord(AuditEventType type, AuditLevel level) {
|
||||
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
|
||||
}
|
||||
|
||||
private boolean shouldRecord(String type, AuditLevel level) {
|
||||
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current license permits recording this event type. Enterprise records everything;
|
||||
* without it only document-processing events (PDF_PROCESS, FILE_OPERATION) are captured,
|
||||
* because they back the Documents tab that is open to every Processor user (still subject to
|
||||
* audit being enabled at a level that includes them). Everything else stays Enterprise-only.
|
||||
*/
|
||||
private boolean isLicensedToRecord(AuditEventType type) {
|
||||
return runningEE
|
||||
|| type == AuditEventType.PDF_PROCESS
|
||||
|| type == AuditEventType.FILE_OPERATION;
|
||||
}
|
||||
|
||||
private boolean isLicensedToRecord(String type) {
|
||||
return runningEE
|
||||
|| AuditEventType.PDF_PROCESS.name().equals(type)
|
||||
|| AuditEventType.FILE_OPERATION.name().equals(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add timing and response status data to the audit record
|
||||
*
|
||||
|
||||
+14
-7
@@ -76,7 +76,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("shouldAudit false proceeds without recording")
|
||||
void skipsWhenShouldAuditFalse() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(false);
|
||||
when(jp.proceed()).thenReturn("ok");
|
||||
|
||||
Object result = aspect.auditGetMethod(jp);
|
||||
@@ -102,7 +103,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("records success outcome and returns result")
|
||||
void recordsSuccess() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -134,7 +136,8 @@ class ControllerAuditAspectTest {
|
||||
MDC.put("auditPrincipal", "fromMdc");
|
||||
MDC.put("auditOrigin", "API");
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
.thenReturn(new HashMap<>());
|
||||
when(auditService.resolveEventType(
|
||||
@@ -166,7 +169,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("records failure outcome and rethrows")
|
||||
void recordsFailureAndRethrows() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -204,7 +208,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("annotated method proceeds without double-auditing")
|
||||
void annotatedMethodSkips() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("annotatedEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(jp.proceed()).thenReturn("ok");
|
||||
@@ -232,7 +237,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("captures result when enabled and non-UI type")
|
||||
void capturesResult() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -262,7 +268,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("UI_DATA result is not captured")
|
||||
void uiDataResultSkipped() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
class AuditCleanupServiceTest {
|
||||
|
||||
private final PersistentAuditEventRepository repository =
|
||||
mock(PersistentAuditEventRepository.class);
|
||||
|
||||
private AuditCleanupService service(boolean runningEE, int retentionDays) {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
var audit = props.getPremium().getEnterpriseFeatures().getAudit();
|
||||
audit.setEnabled(true);
|
||||
audit.setRetentionDays(retentionDays);
|
||||
return new AuditCleanupService(
|
||||
repository, new AuditConfigurationProperties(props), runningEE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Enterprise keeps the configured retention, including infinite")
|
||||
void enterpriseUsesConfigured() {
|
||||
assertThat(service(true, 90).effectiveRetentionDays()).isEqualTo(90);
|
||||
assertThat(service(true, 365).effectiveRetentionDays()).isEqualTo(365);
|
||||
assertThat(service(true, 0).effectiveRetentionDays()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise caps retention at 30 days")
|
||||
void nonEnterpriseCapsHigherValues() {
|
||||
assertThat(service(false, 90).effectiveRetentionDays()).isEqualTo(30);
|
||||
assertThat(service(false, 365).effectiveRetentionDays()).isEqualTo(30);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise respects a shorter configured retention")
|
||||
void nonEnterpriseRespectsLower() {
|
||||
assertThat(service(false, 14).effectiveRetentionDays()).isEqualTo(14);
|
||||
assertThat(service(false, 7).effectiveRetentionDays()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise cannot retain forever (<= 0 becomes the cap)")
|
||||
void nonEnterpriseNoInfinite() {
|
||||
assertThat(service(false, 0).effectiveRetentionDays()).isEqualTo(30);
|
||||
assertThat(service(false, -1).effectiveRetentionDays()).isEqualTo(30);
|
||||
}
|
||||
}
|
||||
+36
@@ -166,6 +166,42 @@ class AuditServiceTest {
|
||||
verify(repository, never()).add(any(AuditEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("records document-processing events even without EE (Documents feed)")
|
||||
void recordsDocumentEventsWithoutEE() {
|
||||
AuditService nonEe =
|
||||
new AuditService(
|
||||
repository, auditConfig, false, pdfDocumentFactory, jwtService);
|
||||
authenticateAs("alice");
|
||||
|
||||
nonEe.audit(AuditEventType.PDF_PROCESS, new HashMap<>(), AuditLevel.BASIC);
|
||||
nonEe.audit(AuditEventType.FILE_OPERATION, new HashMap<>(), AuditLevel.BASIC);
|
||||
|
||||
org.mockito.ArgumentCaptor<AuditEvent> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(AuditEvent.class);
|
||||
verify(repository, org.mockito.Mockito.times(2)).add(captor.capture());
|
||||
assertThat(captor.getAllValues())
|
||||
.extracting(AuditEvent::getType)
|
||||
.containsExactly(
|
||||
AuditEventType.PDF_PROCESS.name(),
|
||||
AuditEventType.FILE_OPERATION.name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("type-aware shouldAudit lets doc events through without EE, blocks others")
|
||||
void typeAwareShouldAuditWithoutEE() throws Exception {
|
||||
AuditService nonEe =
|
||||
new AuditService(
|
||||
repository, auditConfig, false, pdfDocumentFactory, jwtService);
|
||||
Method m = Object.class.getMethod("toString");
|
||||
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.PDF_PROCESS, m, auditConfig)).isTrue();
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.FILE_OPERATION, m, auditConfig)).isTrue();
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isFalse();
|
||||
// With EE, non-doc events at/under the configured level audit too.
|
||||
assertThat(service.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips when audit disabled")
|
||||
void skipsWhenDisabled() {
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
|
||||
/**
|
||||
* SaaS documents visibility: platform admins see the whole server; every other portal user sees
|
||||
* their own team's documents (by member email).
|
||||
*/
|
||||
@Component
|
||||
@Primary
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class SaasPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
|
||||
|
||||
private final TeamSecurityExpressions teamSecurity;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
if (PortalAuditScopeResolver.hasAdminAuthority()) {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
Long teamId = teamSecurity.currentUserTeamId();
|
||||
if (teamId == null) {
|
||||
return PortalAuditScope.denied();
|
||||
}
|
||||
List<String> memberEmails =
|
||||
membershipRepository.findByTeamId(teamId).stream()
|
||||
.map(m -> m.getUser() == null ? null : m.getUser().getEmail())
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return PortalAuditScope.team("team:" + teamId, memberEmails);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,16 @@ import { QueryClient, type DefaultOptions } from "@tanstack/react-query";
|
||||
export const baseQueryOptions: DefaultOptions["queries"] = {
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
// Retry once on transient failures, but never on a 4xx: an auth/forbidden/not-found
|
||||
// response won't change on a second identical request, so retrying just doubles the
|
||||
// wait (e.g. a 403 firing twice) before the UI settles.
|
||||
retry: (failureCount, error) => {
|
||||
const status = (error as { status?: number } | null)?.status;
|
||||
if (typeof status === "number" && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 1;
|
||||
},
|
||||
networkMode: "always",
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// SaaS enterprise flag: derived from the plan tier (wallet-backed), not a local
|
||||
// license bean. Shadows the self-hosted app-config version so Enterprise-only
|
||||
// surfaces (e.g. Infrastructure > Audit) unlock for enterprise-plan tenants.
|
||||
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import type { EnterpriseState } from "@portal-proprietary/hooks/useEnterpriseEnabled";
|
||||
|
||||
export type { EnterpriseState };
|
||||
|
||||
export function useEnterpriseEnabled(): EnterpriseState {
|
||||
const { tier } = useTier();
|
||||
return { enabled: tier === "enterprise", loading: false };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
@@ -7,6 +7,12 @@ vi.mock("react-i18next", () => ({
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const enterprise = { enabled: true, loading: false };
|
||||
vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({
|
||||
useEnterpriseEnabled: () => enterprise,
|
||||
}));
|
||||
|
||||
// Stub the live tab panels so the test doesn't pull their data dependencies.
|
||||
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
|
||||
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
|
||||
@@ -18,6 +24,11 @@ vi.mock("@portal/components/infrastructure/AuditTab", () => ({
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
|
||||
describe("Infrastructure (SaaS)", () => {
|
||||
beforeEach(() => {
|
||||
enterprise.enabled = true;
|
||||
enterprise.loading = false;
|
||||
});
|
||||
|
||||
it("defaults to the live API keys tab and drops the manage-editor button", () => {
|
||||
render(<Infrastructure />);
|
||||
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
|
||||
@@ -41,4 +52,14 @@ describe("Infrastructure (SaaS)", () => {
|
||||
}),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("disables the audit tab for non-enterprise tenants", () => {
|
||||
enterprise.enabled = false;
|
||||
render(<Infrastructure />);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /portal.infrastructure.tabs.audit/,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tabs, type TabItem } from "@app/ui";
|
||||
import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled";
|
||||
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
|
||||
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
|
||||
import "@portal/views/Infrastructure.css";
|
||||
@@ -21,6 +22,8 @@ type InfraTab =
|
||||
export function Infrastructure() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<InfraTab>("api-keys");
|
||||
// Audit is Enterprise-only; disabled (greyed, inert) for non-enterprise tenants.
|
||||
const auditEnabled = useEnterpriseEnabled().enabled;
|
||||
|
||||
const comingSoon = (labelKey: string) => (
|
||||
<>
|
||||
@@ -33,7 +36,11 @@ export function Infrastructure() {
|
||||
|
||||
const tabs: TabItem<InfraTab>[] = [
|
||||
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
|
||||
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
|
||||
{
|
||||
key: "audit",
|
||||
label: t("portal.infrastructure.tabs.audit"),
|
||||
disabled: !auditEnabled,
|
||||
},
|
||||
{
|
||||
key: "deployments",
|
||||
label: comingSoon("portal.infrastructure.tabs.deployments"),
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
|
||||
type AuditFilter = "all" | AuditCategory;
|
||||
|
||||
// Enterprise-only: the Infrastructure view disables this tab for non-enterprise
|
||||
// instances, so this component only ever renders when entitled. The backend still
|
||||
// scopes the log to admins / team leads (403 -> forbidden state below).
|
||||
export function AuditTab() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Enterprise-license flag for the portal, from the backend app-config (`runningEE`).
|
||||
// Gates Enterprise-only surfaces (e.g. Infrastructure > Audit) so they show a locked
|
||||
// upsell instead of firing a doomed 403 request. The SaaS build shadows this file to
|
||||
// derive enterprise from the plan tier (wallet-backed) - see portal-saas.
|
||||
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
export interface EnterpriseState {
|
||||
enabled: boolean;
|
||||
// True while the flag is still resolving, so callers can hold rather than flash a lock.
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useEnterpriseEnabled(): EnterpriseState {
|
||||
const { config, loading } = useAppConfig();
|
||||
return { enabled: Boolean(config?.runningEE), loading };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
@@ -14,6 +14,11 @@ vi.mock("@portal/contexts/ViewContext", () => ({
|
||||
useView: () => ({ setActiveView: vi.fn() }),
|
||||
}));
|
||||
|
||||
const enterprise = { enabled: true, loading: false };
|
||||
vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({
|
||||
useEnterpriseEnabled: () => enterprise,
|
||||
}));
|
||||
|
||||
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
|
||||
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
|
||||
}));
|
||||
@@ -42,6 +47,11 @@ function tabButtons() {
|
||||
}
|
||||
|
||||
describe("Infrastructure view", () => {
|
||||
beforeEach(() => {
|
||||
enterprise.enabled = true;
|
||||
enterprise.loading = false;
|
||||
});
|
||||
|
||||
it("orders the working tabs first and defaults to API keys", () => {
|
||||
renderView();
|
||||
|
||||
@@ -91,4 +101,24 @@ describe("Infrastructure view", () => {
|
||||
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables the audit tab (inert, never opens) for non-enterprise users", () => {
|
||||
enterprise.enabled = false;
|
||||
renderView();
|
||||
|
||||
const auditBtn = screen.getByRole("button", { name: `${T}.audit` });
|
||||
expect(auditBtn).toBeDisabled();
|
||||
|
||||
fireEvent.click(auditBtn);
|
||||
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores a ?tab=audit deep link when not enterprise", () => {
|
||||
enterprise.enabled = false;
|
||||
renderView("/infrastructure?tab=audit");
|
||||
|
||||
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Tabs, type TabItem } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled";
|
||||
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
|
||||
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
|
||||
import "@portal/views/Infrastructure.css";
|
||||
@@ -12,30 +13,39 @@ type InfraTab = "api-keys" | "audit";
|
||||
/** Shown but inert: no backend behind these screens yet. */
|
||||
type DisabledInfraTab = "deployments" | "security" | "models" | "storage";
|
||||
|
||||
const ENABLED_TABS: InfraTab[] = ["api-keys", "audit"];
|
||||
|
||||
export function Infrastructure() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<InfraTab>("api-keys");
|
||||
const { setActiveView } = useView();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Audit is Enterprise-only; disabled (greyed, inert) on non-enterprise instances.
|
||||
const auditEnabled = useEnterpriseEnabled().enabled;
|
||||
|
||||
const canOpenTab = useCallback(
|
||||
(key: string) => key === "api-keys" || (key === "audit" && auditEnabled),
|
||||
[auditEnabled],
|
||||
);
|
||||
|
||||
// Deep-link (?tab=<key>) from elsewhere (e.g. the home visualiser's outcome
|
||||
// cards → audit log): open that tab, then drop the param.
|
||||
useEffect(() => {
|
||||
const requested = searchParams.get("tab");
|
||||
if (!requested) return;
|
||||
if ((ENABLED_TABS as string[]).includes(requested)) {
|
||||
if (canOpenTab(requested)) {
|
||||
setTab(requested as InfraTab);
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("tab");
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
}, [searchParams, setSearchParams, canOpenTab]);
|
||||
|
||||
const tabs: TabItem<InfraTab | DisabledInfraTab>[] = [
|
||||
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
|
||||
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
|
||||
{
|
||||
key: "audit",
|
||||
label: t("portal.infrastructure.tabs.audit"),
|
||||
disabled: !auditEnabled,
|
||||
},
|
||||
{
|
||||
key: "deployments",
|
||||
label: t("portal.infrastructure.tabs.deployments"),
|
||||
@@ -78,7 +88,7 @@ export function Infrastructure() {
|
||||
items={tabs}
|
||||
activeKey={tab}
|
||||
onChange={(key) => {
|
||||
if ((ENABLED_TABS as string[]).includes(key)) setTab(key as InfraTab);
|
||||
if (canOpenTab(key)) setTab(key as InfraTab);
|
||||
}}
|
||||
variant="underline"
|
||||
ariaLabel={t("portal.infrastructure.sectionsAriaLabel")}
|
||||
|
||||
Reference in New Issue
Block a user