diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
index 2f75b755e0..e6af47129f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
@@ -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()
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalDocumentsScopeResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalDocumentsScopeResolver.java
new file mode 100644
index 0000000000..066e74f4c5
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalDocumentsScopeResolver.java
@@ -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();
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalDocumentsScopeResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalDocumentsScopeResolver.java
new file mode 100644
index 0000000000..2767de0630
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalDocumentsScopeResolver.java
@@ -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();
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java
index 14146eacb4..f6d094bde4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java
@@ -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.
+ *
+ *
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 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 =
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java
index 8a70a1b7a4..df1d762963 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java
@@ -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.
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java
index 2fee8b5c7c..87fa165826 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java
@@ -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 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 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 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 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 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 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
*
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java
index e8d8ae7a73..da4a48079a 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java
@@ -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)))
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditCleanupServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditCleanupServiceTest.java
new file mode 100644
index 0000000000..62e80d5280
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditCleanupServiceTest.java
@@ -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);
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java
index c2d14d1307..ab0b3437bb 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java
@@ -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 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() {
diff --git a/app/saas/src/main/java/stirling/software/saas/security/SaasPortalDocumentsScopeResolver.java b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalDocumentsScopeResolver.java
new file mode 100644
index 0000000000..08cd8d3788
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalDocumentsScopeResolver.java
@@ -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 memberEmails =
+ membershipRepository.findByTeamId(teamId).stream()
+ .map(m -> m.getUser() == null ? null : m.getUser().getEmail())
+ .filter(Objects::nonNull)
+ .toList();
+ return PortalAuditScope.team("team:" + teamId, memberEmails);
+ }
+}
diff --git a/frontend/editor/src/core/query/queryClient.ts b/frontend/editor/src/core/query/queryClient.ts
index 989ebff8c5..91b11f6e3d 100644
--- a/frontend/editor/src/core/query/queryClient.ts
+++ b/frontend/editor/src/core/query/queryClient.ts
@@ -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,
};
diff --git a/frontend/editor/src/portal-saas/hooks/useEnterpriseEnabled.ts b/frontend/editor/src/portal-saas/hooks/useEnterpriseEnabled.ts
new file mode 100644
index 0000000000..79127f669e
--- /dev/null
+++ b/frontend/editor/src/portal-saas/hooks/useEnterpriseEnabled.ts
@@ -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 };
+}
diff --git a/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx b/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx
index f884d7e6f4..205c9b170c 100644
--- a/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx
+++ b/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx
@@ -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: () => ,
@@ -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();
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();
+ expect(
+ screen.getByRole("button", {
+ name: /portal.infrastructure.tabs.audit/,
+ }),
+ ).toBeDisabled();
+ });
});
diff --git a/frontend/editor/src/portal-saas/views/Infrastructure.tsx b/frontend/editor/src/portal-saas/views/Infrastructure.tsx
index f52453e716..66204db202 100644
--- a/frontend/editor/src/portal-saas/views/Infrastructure.tsx
+++ b/frontend/editor/src/portal-saas/views/Infrastructure.tsx
@@ -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("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[] = [
{ 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"),
diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
index 71ae52ac8b..548934d58a 100644
--- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
@@ -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();
diff --git a/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts b/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts
new file mode 100644
index 0000000000..7b471447fb
--- /dev/null
+++ b/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts
@@ -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 };
+}
diff --git a/frontend/editor/src/portal/views/Infrastructure.test.tsx b/frontend/editor/src/portal/views/Infrastructure.test.tsx
index cda36caef8..afd23e0ff1 100644
--- a/frontend/editor/src/portal/views/Infrastructure.test.tsx
+++ b/frontend/editor/src/portal/views/Infrastructure.test.tsx
@@ -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: () => ,
}));
@@ -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();
+ });
});
diff --git a/frontend/editor/src/portal/views/Infrastructure.tsx b/frontend/editor/src/portal/views/Infrastructure.tsx
index 64ef579b55..fa1ff272b9 100644
--- a/frontend/editor/src/portal/views/Infrastructure.tsx
+++ b/frontend/editor/src/portal/views/Infrastructure.tsx
@@ -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("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=) 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[] = [
{ 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")}