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")}