diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index da564e454b..7c1190926b 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -72,6 +72,8 @@ spring.datasource.username=sa spring.datasource.password= spring.h2.console.enabled=false spring.jpa.hibernate.ddl-auto=update +# Batch associations into IN() loads so list endpoints don't N+1 as tables grow. +spring.jpa.properties.hibernate.default_batch_fetch_size=100 # Defer datasource initialization to ensure that the database is fully set up # before Hibernate attempts to access it. This is particularly useful when # using database initialization scripts or tools. diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 1e51b820ae..60f6541c82 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -103,6 +103,7 @@ dependencies { testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}" testImplementation "org.testcontainers:minio:${testcontainersMinioVersion}" testImplementation "org.testcontainers:localstack:${testcontainersMinioVersion}" + testImplementation "org.testcontainers:postgresql:${testcontainersMinioVersion}" testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}" } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java index 72159829a4..b3c987d6f2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.access.service; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -42,6 +43,47 @@ public class ResourceAccessService { return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user); } + /** Portal access for a roster (admin, grant, or default policy). */ + public Set usersWithPortalAccess(Collection users, Set teamLeaderUserIds) { + Set grantedPrincipals = new HashSet<>(); + for (ResourceGrant g : + grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) { + if (permissionSatisfies(g.getPermission(), AccessPermission.USE)) { + grantedPrincipals.add(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId())); + } + } + Set leaderIds = teamLeaderUserIds == null ? Set.of() : teamLeaderUserIds; + Set allowed = new HashSet<>(); + for (User user : users) { + if (user != null + && user.getId() != null + && hasPortalAccess(user, grantedPrincipals, leaderIds)) { + allowed.add(user.getId()); + } + } + return allowed; + } + + private boolean hasPortalAccess( + User user, Set grantedPrincipals, Set leaderIds) { + if (isAdmin(user)) { + return true; + } + for (PrincipalRef principal : principalResolver.principalsOf(user)) { + if (grantedPrincipals.contains(principal)) { + return true; + } + } + if (portalDefaultPolicy == null) { + return false; + } + return switch (portalDefaultPolicy) { + case ORG_ALL -> principalResolver.allowsDeploymentWideAccess(); + case ADMINS_AND_TEAM_LEADS -> leaderIds.contains(user.getId()); + case EXPLICIT_ONLY -> false; + }; + } + /** Whether the user may use a resource, falling back to its default policy. */ public boolean canUseResource( ResourceType type, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 8db5f48174..2c0b995b8b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -3,7 +3,6 @@ package stirling.software.proprietary.controller.api; import static stirling.software.common.util.ProviderUtils.validateProvider; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; @@ -45,7 +44,6 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.security.database.repository.SessionRepository; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; -import stirling.software.proprietary.security.model.SessionEntity; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.dto.AdminUserSummary; import stirling.software.proprietary.security.repository.TeamMembershipRepository; @@ -169,16 +167,8 @@ public class ProprietaryUIDataController { boolean isFirstTimeSetup = false; boolean showDefaultCredentials = false; - List allUsers = userRepository.findAll(); - List realUsers = - allUsers.stream() - .filter( - user -> - !Role.INTERNAL_API_USER - .getRoleId() - .equals(user.getUsername())) - .toList(); - long userCount = realUsers.size(); + // Count real users, excluding the internal API user. + long userCount = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId()); if (userCount == 0) { isFirstTimeSetup = true; @@ -265,92 +255,67 @@ public class ProprietaryUIDataController { @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Get admin settings data") public ResponseEntity getAdminSettingsData(Authentication authentication) { - List allUsers = userRepository.findAllWithTeam(); - Iterator iterator = allUsers.iterator(); + List allUsers = userRepository.findAllWithTeamAndAuthorities(); Map roleDetails = Role.getAllRoleDetails(); + // Drop the internal API user and internal-team members; the roster never shows them. + boolean hasInternalApiUser = false; + List visibleUsers = new ArrayList<>(allUsers.size()); + for (User user : allUsers) { + if (user == null) { + continue; + } + if (isInternalApiUser(user)) { + hasInternalApiUser = true; + continue; + } + if (user.getTeam() != null + && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { + continue; + } + visibleUsers.add(user); + } + if (hasInternalApiUser) { + roleDetails.remove(Role.INTERNAL_API_USER.getRoleId()); + } + + // All users' settings in one query (mfaSecret masked). + Map> settingsByUserId = + loadSettingsByUserId(visibleUsers.stream().map(User::getId).toList()); + + // Active = any non-expired session within the inactivity window; expiry is left to + // SessionScheduled. + int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); + Instant activeCutoff = Instant.now().minusSeconds(maxInactiveInterval); + Map lastRequestByPrincipal = new HashMap<>(); + for (Object[] row : sessionRepository.findLatestRequestPerPrincipal()) { + if (row[0] != null) { + lastRequestByPrincipal.put((String) row[0], (Instant) row[1]); + } + } + Set activePrincipals = + new HashSet<>(sessionRepository.findActivePrincipalsSince(activeCutoff)); + Map userSessions = new HashMap<>(); Map userLastRequest = new HashMap<>(); Map> userSettings = new HashMap<>(); int activeUsers = 0; int disabledUsers = 0; - - while (iterator.hasNext()) { - User user = iterator.next(); - if (user != null) { - String username = user.getUsername(); - boolean shouldRemove = false; - - // Check if user is an INTERNAL_API_USER - for (Authority authority : user.getAuthorities()) { - if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) { - shouldRemove = true; - roleDetails.remove(Role.INTERNAL_API_USER.getRoleId()); - break; - } - } - - // Check if user is part of the Internal team - if (user.getTeam() != null - && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { - shouldRemove = true; - } - - if (shouldRemove) { - iterator.remove(); - continue; - } - - // Session status and last request time - int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); - boolean hasActiveSession = false; - Date lastRequest = null; - Optional latestSession = - sessionPersistentRegistry.findLatestSession(username); - - if (latestSession.isPresent()) { - SessionEntity sessionEntity = latestSession.get(); - Instant lastAccessedTime = - Optional.ofNullable(sessionEntity.getLastRequest()) - .orElse(Instant.EPOCH); - Instant now = Instant.now(); - Instant expirationTime = - lastAccessedTime.plus(maxInactiveInterval, ChronoUnit.SECONDS); - - if (now.isAfter(expirationTime)) { - sessionPersistentRegistry.expireSession(sessionEntity.getSessionId()); - } else { - hasActiveSession = !sessionEntity.isExpired(); - } - lastRequest = Date.from(lastAccessedTime); - } else { - lastRequest = new Date(0); - } - - User userWithSettings = - userRepository.findByIdWithSettings(user.getId()).orElse(user); - - // Mask mfaSecret if present in settings - Map originalSettings = userWithSettings.getSettings(); - Map settingsCopy = - originalSettings != null - ? new HashMap<>(originalSettings) - : new HashMap<>(); - if (settingsCopy.containsKey("mfaSecret")) { - settingsCopy.put("mfaSecret", "********"); - } - userSettings.put(username, settingsCopy); - userSessions.put(username, hasActiveSession); - userLastRequest.put(username, lastRequest); - - if (hasActiveSession) activeUsers++; - if (!user.isEnabled()) disabledUsers++; - } + for (User user : visibleUsers) { + String username = user.getUsername(); + boolean hasActiveSession = activePrincipals.contains(username); + Instant lastRequest = lastRequestByPrincipal.get(username); + userSessions.put(username, hasActiveSession); + userLastRequest.put( + username, lastRequest != null ? Date.from(lastRequest) : new Date(0)); + userSettings.put(username, maskSecrets(settingsByUserId.get(user.getId()))); + if (hasActiveSession) activeUsers++; + if (!user.isEnabled()) disabledUsers++; } // Sort users by active status and last request date List sortedUsers = - allUsers.stream() + visibleUsers.stream() .sorted( (u1, u2) -> { boolean u1Active = userSessions.get(u1.getUsername()); @@ -380,11 +345,13 @@ public class ProprietaryUIDataController { int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers(); boolean premiumEnabled = applicationProperties.getPremium().isEnabled(); - // Convert User entities to AdminUserSummary DTOs to exclude sensitive fields + // Resolve portal access for the whole roster. Set leaderUserIds = leaderUserIds(); + Set portalAccessUserIds = + resourceAccessService.usersWithPortalAccess(sortedUsers, leaderUserIds); List userSummaries = sortedUsers.stream() - .map(user -> convertUserToSummary(user, leaderUserIds)) + .map(user -> convertUserToSummary(user, leaderUserIds, portalAccessUserIds)) .toList(); AdminSettingsData data = new AdminSettingsData(); @@ -393,7 +360,7 @@ public class ProprietaryUIDataController { data.setRoleDetails(roleDetails); data.setUserSessions(userSessions); data.setUserLastRequest(userLastRequest); - data.setTotalUsers(allUsers.size()); + data.setTotalUsers(visibleUsers.size()); data.setActiveUsers(activeUsers); data.setDisabledUsers(disabledUsers); data.setTeams(allTeams); @@ -516,7 +483,8 @@ public class ProprietaryUIDataController { } List teamUsers = userRepository.findAllByTeamId(id); - List allUsers = userRepository.findAllWithTeam(); + // Fetch authorities + team for the available-users list. + List allUsers = userRepository.findAllWithTeamAndAuthorities(); List availableUsers = allUsers.stream() .filter( @@ -575,17 +543,48 @@ public class ProprietaryUIDataController { .collect(Collectors.toSet()); } + /** Whether the user holds the internal-API authority (never shown in the roster). */ + private boolean isInternalApiUser(User user) { + for (Authority authority : user.getAuthorities()) { + if (Role.INTERNAL_API_USER.getRoleId().equals(authority.getAuthority())) { + return true; + } + } + return false; + } + + /** Assemble per-user settings maps from the flat (id, key, value) rows of one bulk query. */ + private Map> loadSettingsByUserId(List userIds) { + Map> byUser = new HashMap<>(); + if (userIds.isEmpty()) { + return byUser; + } + for (Object[] row : userRepository.findSettingsByUserIds(userIds)) { + byUser.computeIfAbsent((Long) row[0], id -> new HashMap<>()) + .put((String) row[1], (String) row[2]); + } + return byUser; + } + + /** Copy a settings map with mfaSecret masked; null-safe. */ + private Map maskSecrets(Map settings) { + Map copy = settings != null ? new HashMap<>(settings) : new HashMap<>(); + if (copy.containsKey("mfaSecret")) { + copy.put("mfaSecret", "********"); + } + return copy; + } + /** - * Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and - * apiKey. + * Convert a User to AdminUserSummary (excludes sensitive fields); portal access is passed in. */ - private AdminUserSummary convertUserToSummary(User user, Set leaderUserIds) { + private AdminUserSummary convertUserToSummary( + User user, Set leaderUserIds, Set portalAccessUserIds) { AdminUserSummary summary = new AdminUserSummary(); summary.setId(user.getId()); summary.setTeamLead(leaderUserIds.contains(user.getId())); - // Authoritative portal access, same call /me uses, so the roster honors the configured - // policy instead of the frontend guessing from role/team-leadership. - summary.setPortalAccess(resourceAccessService.canAccessPortal(user)); + // Portal access (same policy /me uses). + summary.setPortalAccess(portalAccessUserIds.contains(user.getId())); summary.setUsername(user.getUsername()); summary.setEmail(user.getUsername()); // Use username as email for consistency summary.setRoleName(user.getRoleName()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java index de121939bb..08de0b190d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java @@ -24,7 +24,17 @@ import stirling.software.proprietary.security.model.User; @Entity @Table( name = "team_memberships", - uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})}) + uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})}, + // Match the saas migration names so ddl-auto skips them on saas (index already there) and + // only creates them on self-hosted, which has no migrations. + indexes = { + @Index( + name = "idx_team_memberships_user_role", + columnList = "user_id, role"), // leader-set lookups + @Index( + name = "idx_team_memberships_team_role", + columnList = "team_id, role") // per-team member lists + }) @NoArgsConstructor @Getter @Setter diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java index db5d5a9b33..af35516cdb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java @@ -45,4 +45,29 @@ public interface SessionRepository extends JpaRepository + "WHERE u.team.id = :teamId " + "GROUP BY u.username") List findLatestSessionByTeamId(@Param("teamId") Long teamId); + + /** Latest request instant per principal. */ + @Query( + "SELECT s.principalName, MAX(s.lastRequest) FROM SessionEntity s GROUP BY s.principalName") + List findLatestRequestPerPrincipal(); + + /** Principals with a live (non-expired, within-window) session. */ + @Query( + "SELECT DISTINCT s.principalName FROM SessionEntity s " + + "WHERE s.expired = false AND s.lastRequest > :cutoff") + List findActivePrincipalsSince(@Param("cutoff") Instant cutoff); + + /** Flag timed-out sessions as expired. */ + @Modifying + @Transactional + @Query( + "UPDATE SessionEntity s SET s.expired = true " + + "WHERE s.expired = false AND s.lastRequest < :cutoff") + int expireOlderThan(@Param("cutoff") Instant cutoff); + + /** Purge long-expired sessions to bound table growth. */ + @Modifying + @Transactional + @Query("DELETE FROM SessionEntity s WHERE s.expired = true AND s.lastRequest < :cutoff") + int deleteExpiredOlderThan(@Param("cutoff") Instant cutoff); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index acae6044de..c7aa508e31 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -1,11 +1,13 @@ package stirling.software.proprietary.security.database.repository; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -43,6 +45,15 @@ public interface UserRepository extends JpaRepository { @Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team") List findAllWithTeam(); + /** All users with team + authorities fetched (DISTINCT dedupes the collection join). */ + @EntityGraph(attributePaths = {"team", "authorities"}) + @Query("SELECT DISTINCT u FROM User u") + List findAllWithTeamAndAuthorities(); + + /** (userId, key, value) settings rows for the given users. */ + @Query("SELECT u.id, KEY(s), VALUE(s) FROM User u JOIN u.settings s WHERE u.id IN :ids") + List findSettingsByUserIds(@Param("ids") Collection ids); + @Query( "SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId") List findAllByTeamId(@Param("teamId") Long teamId); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 9adcce1ad3..659f7691bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -11,6 +11,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -19,7 +20,10 @@ import lombok.Getter; import lombok.Setter; @Entity -@Table(name = "authorities") +@Table( + name = "authorities", + // index the FK: authorities load by user_id + indexes = @Index(name = "idx_authorities_user_id", columnList = "user_id")) @Getter @Setter public class Authority implements GrantedAuthority, Serializable { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java index 7e9da2cb7d..552d97d022 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java @@ -5,13 +5,23 @@ import java.time.Instant; import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.Data; @Entity @Data -@Table(name = "sessions") +@Table( + name = "sessions", + indexes = { + // per-principal session/activity lookups + @Index( + name = "idx_sessions_principal_last", + columnList = "principal_name, last_request"), + // scheduled expiry/purge scan + @Index(name = "idx_sessions_expired", columnList = "expired") + }) public class SessionEntity implements Serializable { @Id private String sessionId; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index eeeda1823b..32733f5fc5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -28,7 +28,10 @@ import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.model.Team; @Entity -@Table(name = "users") +@Table( + name = "users", + // team_id backs Team.users joins, the admin roster fetch, and per-team user counts. + indexes = @Index(name = "idx_users_team_id", columnList = "team_id")) @NoArgsConstructor @Getter @Setter diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index 3961107870..19adb9a970 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -146,6 +146,16 @@ public class SessionPersistentRegistry implements SessionRegistry { return sessionRepository.findAll(); } + // Flag every session idle past the timeout. + public int expireStaleSessions() { + return sessionRepository.expireOlderThan(Instant.now().minus(defaultMaxInactiveInterval)); + } + + // Purge sessions expired longer than the retention window. + public int purgeExpiredSessions(Duration retention) { + return sessionRepository.deleteExpiredOlderThan(Instant.now().minus(retention)); + } + // Mark a session as expired public void expireSession(String sessionId) { Optional sessionEntityOpt = sessionRepository.findById(sessionId); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java index 1f491bf4d6..8afde4959d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java @@ -1,12 +1,8 @@ package stirling.software.proprietary.security.session; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Date; -import java.util.List; +import java.time.Duration; import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.security.core.session.SessionInformation; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; @@ -15,23 +11,15 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public class SessionScheduled { + // Retention before an expired session is purged. + private static final Duration EXPIRED_SESSION_RETENTION = Duration.ofDays(30); + private final SessionPersistentRegistry sessionPersistentRegistry; @Scheduled(cron = "0 0/5 * * * ?") public void expireSessions() { - Instant now = Instant.now(); - for (Object principal : sessionPersistentRegistry.getAllPrincipals()) { - List sessionInformations = - sessionPersistentRegistry.getAllSessions(principal, false); - for (SessionInformation sessionInformation : sessionInformations) { - Date lastRequest = sessionInformation.getLastRequest(); - int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); - Instant expirationTime = - lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); - if (now.isAfter(expirationTime)) { - sessionPersistentRegistry.expireSession(sessionInformation.getSessionId()); - } - } - } + // Flag timed-out sessions, then purge long-dead ones. + sessionPersistentRegistry.expireStaleSessions(); + sessionPersistentRegistry.purgeExpiredSessions(EXPIRED_SESSION_RETENTION); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessPortalBulkParityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessPortalBulkParityTest.java new file mode 100644 index 0000000000..858de91726 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessPortalBulkParityTest.java @@ -0,0 +1,151 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.model.AccessPermission; +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; + +/** Bulk portal-access must match per-user canAccessPortal for every policy. */ +@ExtendWith(MockitoExtension.class) +class ResourceAccessPortalBulkParityTest { + + @Mock private ResourceGrantRepository grantRepository; + @Mock private TeamLeadLookup teamLeadLookup; + + private ResourceAccessService service; + + private User admin; + private User leader; + private User userGrantHolder; + private User teamGrantMember; + private User plainMember; + private List everyone; + private Set leaderUserIds; + + void setUp(DefaultAccessPolicy policy) { + service = + new ResourceAccessService( + grantRepository, teamLeadLookup, new DefaultPrincipalResolver()); + ReflectionTestUtils.setField(service, "portalDefaultPolicy", policy); + + admin = user(1L, null, Role.ADMIN.getRoleId()); + leader = user(2L, 10L, Role.USER.getRoleId()); + userGrantHolder = user(3L, null, Role.USER.getRoleId()); + teamGrantMember = user(4L, 20L, Role.USER.getRoleId()); + plainMember = user(5L, 10L, Role.USER.getRoleId()); + everyone = List.of(admin, leader, userGrantHolder, teamGrantMember, plainMember); + + // Grants: a USER grant to #3 and a TEAM grant to team 20 (which #4 belongs to). + lenient() + .when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) + .thenReturn( + List.of( + grant(PrincipalType.USER, 3L, AccessPermission.USE), + grant(PrincipalType.TEAM, 20L, AccessPermission.USE))); + + // Only #2 leads a team; leaderUserIds is what the controller passes to the bulk method. + lenient().when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true); + leaderUserIds = Set.of(2L); + } + + @Test + void bulkMatchesPerUserForAdminsAndTeamLeadsPolicy() { + assertParity(DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + } + + @Test + void bulkMatchesPerUserForOrgAllPolicy() { + assertParity(DefaultAccessPolicy.ORG_ALL); + } + + @Test + void bulkMatchesPerUserForExplicitOnlyPolicy() { + assertParity(DefaultAccessPolicy.EXPLICIT_ONLY); + } + + /** SaaS no-leak: ORG_ALL grants nobody deployment-wide when the resolver forbids it. */ + @Test + void orgAllDoesNotLeakDeploymentWideWhenResolverForbidsIt() { + DefaultPrincipalResolver base = new DefaultPrincipalResolver(); + PrincipalResolver saasLikeResolver = + new PrincipalResolver() { + @Override + public Set principalsOf(User user) { + return base.principalsOf(user); + } + // allowsDeploymentWideAccess() inherits the interface default (false) = SaaS. + }; + service = new ResourceAccessService(grantRepository, teamLeadLookup, saasLikeResolver); + ReflectionTestUtils.setField(service, "portalDefaultPolicy", DefaultAccessPolicy.ORG_ALL); + lenient() + .when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) + .thenReturn(List.of()); + + User adminUser = user(1L, null, Role.ADMIN.getRoleId()); + User plainMember = user(5L, 10L, Role.USER.getRoleId()); + + Set bulk = service.usersWithPortalAccess(List.of(adminUser, plainMember), Set.of()); + + assertThat(bulk).contains(1L).doesNotContain(5L); + assertThat(service.canAccessPortal(plainMember)) + .as("ORG_ALL must not grant a plain member deployment-wide on a SaaS-like resolver") + .isFalse(); + assertThat(service.canAccessPortal(adminUser)).isTrue(); + } + + private void assertParity(DefaultAccessPolicy policy) { + setUp(policy); + Set bulk = service.usersWithPortalAccess(everyone, leaderUserIds); + for (User user : everyone) { + boolean authoritative = service.canAccessPortal(user); + assertThat(bulk.contains(user.getId())) + .as( + "policy=%s user=%d bulk should equal canAccessPortal(%s)", + policy, user.getId(), authoritative) + .isEqualTo(authoritative); + } + } + + private User user(Long id, Long teamId, String authority) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + new Authority(authority, user); + if (teamId != null) { + Team team = new Team(); + team.setId(teamId); + team.setName("team-" + teamId); + user.setTeam(team); + } + return user; + } + + private ResourceGrant grant(PrincipalType type, Long principalId, AccessPermission permission) { + ResourceGrant grant = new ResourceGrant(); + grant.setResourceType(ResourceType.PORTAL); + grant.setResourceId(""); + grant.setPrincipalType(type); + grant.setPrincipalId(principalId); + grant.setPermission(permission); + return grant; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsPerfHarness.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsPerfHarness.java new file mode 100644 index 0000000000..35b9e354de --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsPerfHarness.java @@ -0,0 +1,255 @@ +package stirling.software.proprietary.controller.api; + +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.access.service.DefaultPrincipalResolver; +import stirling.software.proprietary.access.service.MembershipTeamLeadLookup; +import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.model.UserLicenseSettings; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.SessionEntity; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.DatabaseServiceInterface; +import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.MfaService; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.service.UserLicenseSettingsService; + +import tools.jackson.databind.ObjectMapper; + +/** Shared seeding, wiring, and statement-count measurement for the admin-roster query tests. */ +class AdminSettingsPerfHarness { + + static final Duration SESSION_TIMEOUT = Duration.ofMinutes(30); + private static final Instant STALE = Instant.now().minus(Duration.ofHours(2)); + private static final Instant FRESH = Instant.now().minus(Duration.ofMinutes(2)); + + record Measure(int users, long statements, long updates, long inserts, long millis) {} + + private final UserRepository userRepository; + private final SessionRepository sessionRepository; + private final TeamRepository teamRepository; + private final TeamMembershipRepository teamMembershipRepository; + private final ResourceGrantRepository resourceGrantRepository; + private final EntityManager em; + private final EntityManagerFactory emf; + + AdminSettingsPerfHarness( + UserRepository userRepository, + SessionRepository sessionRepository, + TeamRepository teamRepository, + TeamMembershipRepository teamMembershipRepository, + ResourceGrantRepository resourceGrantRepository, + EntityManager em, + EntityManagerFactory emf) { + this.userRepository = userRepository; + this.sessionRepository = sessionRepository; + this.teamRepository = teamRepository; + this.teamMembershipRepository = teamMembershipRepository; + this.resourceGrantRepository = resourceGrantRepository; + this.em = em; + this.emf = emf; + } + + Measure seedAndMeasure( + ProprietaryUIDataController controller, Authentication auth, int userCount) { + wipe(); + seed(userCount); + em.flush(); + em.clear(); + + Statistics stats = emf.unwrap(SessionFactory.class).getStatistics(); + stats.setStatisticsEnabled(true); + stats.clear(); + + long t0 = System.nanoTime(); + var response = controller.getAdminSettingsData(auth); + int users = response.getBody().getUsers().size(); + em.flush(); // materialise any writes the GET issued so they are counted + long millis = (System.nanoTime() - t0) / 1_000_000; + + return new Measure( + users, + stats.getPrepareStatementCount(), + stats.getEntityUpdateCount(), + stats.getEntityInsertCount(), + millis); + } + + void wipe() { + // Detach anything a prior measure left managed, then delete children before parents. + em.clear(); + teamMembershipRepository.deleteAllInBatch(); + sessionRepository.deleteAllInBatch(); + resourceGrantRepository.deleteAllInBatch(); + // Not deleteAllInBatch: a bulk DELETE bypasses the User->authorities/user_settings cascade. + userRepository.deleteAll(); + em.flush(); + teamRepository.deleteAllInBatch(); + em.flush(); + em.clear(); + } + + void seed(int userCount) { + int teamCount = Math.max(1, userCount / 40); + List teams = new ArrayList<>(teamCount); + for (int i = 0; i < teamCount; i++) { + Team team = new Team(); + team.setName("team-" + i); + teams.add(team); + } + List savedTeams = teamRepository.saveAll(teams); + em.flush(); + + List users = new ArrayList<>(userCount); + List sessions = new ArrayList<>(userCount); + for (int i = 0; i < userCount; i++) { + User user = new User(); + String username = "user-" + i; + user.setUsername(username); + user.setEnabled(true); + user.setTeam(savedTeams.get(i % teamCount)); + new Authority(i == 0 ? Role.ADMIN.getRoleId() : Role.USER.getRoleId(), user); + Map settings = new HashMap<>(); + settings.put("language", "en-GB"); + if (i % 5 == 0) { + settings.put("mfaSecret", "SECRET-" + i); + } + user.setSettings(settings); + users.add(user); + + SessionEntity session = new SessionEntity(); + session.setSessionId(UUID.randomUUID().toString()); + session.setPrincipalName(username); + // ~30% of sessions are past the timeout. + session.setLastRequest(i % 10 < 3 ? STALE : FRESH); + session.setExpired(false); + sessions.add(session); + } + List savedUsers = userRepository.saveAll(users); + sessionRepository.saveAll(sessions); + em.flush(); + + List memberships = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + if (i % 10 == 0) { + TeamMembership membership = new TeamMembership(); + membership.setTeam(savedUsers.get(i).getTeam()); + membership.setUser(savedUsers.get(i)); + membership.setRole(TeamRole.LEADER); + membership.setInvitedAt(LocalDateTime.now()); + memberships.add(membership); + } + } + teamMembershipRepository.saveAll(memberships); + em.flush(); + } + + ProprietaryUIDataController buildController() { + ApplicationProperties applicationProperties = + mock(ApplicationProperties.class, RETURNS_DEEP_STUBS); + + SessionPersistentRegistry sessionRegistry = + new SessionPersistentRegistry(sessionRepository); + ReflectionTestUtils.setField( + sessionRegistry, "defaultMaxInactiveInterval", SESSION_TIMEOUT); + + ResourceAccessService resourceAccessService = + new ResourceAccessService( + resourceGrantRepository, + new MembershipTeamLeadLookup(teamMembershipRepository), + new DefaultPrincipalResolver()); + ReflectionTestUtils.setField( + resourceAccessService, + "portalDefaultPolicy", + DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + + UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class); + UserLicenseSettings licenseSettings = mock(UserLicenseSettings.class); + lenient().when(licenseSettings.getLicenseMaxUsers()).thenReturn(0); + lenient().when(licenseSettingsService.getSettings()).thenReturn(licenseSettings); + lenient().when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(100_000); + lenient().when(licenseSettingsService.getAvailableUserSlots()).thenReturn(100_000L); + lenient().when(licenseSettingsService.getDisplayGrandfatheredCount()).thenReturn(0); + + LoginAttemptService loginAttemptService = mock(LoginAttemptService.class); + lenient().when(loginAttemptService.getAllBlockedUsers()).thenReturn(new ArrayList<>()); + + return new ProprietaryUIDataController( + applicationProperties, + mock(AuditConfigurationProperties.class), + sessionRegistry, + userRepository, + teamRepository, + teamMembershipRepository, + sessionRepository, + mock(DatabaseServiceInterface.class), + mock(ObjectMapper.class), + false, + licenseSettingsService, + mock(PersistentAuditEventRepository.class), + mock(MfaService.class), + loginAttemptService, + resourceAccessService); + } + + Authentication adminAuth() { + Authentication auth = mock(Authentication.class); + lenient().when(auth.getName()).thenReturn("user-0"); + return auth; + } + + User mkUser(String username, Team team, String authority, Map settings) { + User user = new User(); + user.setUsername(username); + user.setEnabled(true); + user.setTeam(team); + new Authority(authority, user); + user.setSettings(new HashMap<>(settings)); + return user; + } + + TeamRepository teams() { + return teamRepository; + } + + UserRepository users() { + return userRepository; + } + + EntityManager em() { + return em; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPerfTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPerfTest.java new file mode 100644 index 0000000000..92ec752610 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPerfTest.java @@ -0,0 +1,194 @@ +package stirling.software.proprietary.controller.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.security.core.Authentication; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceContext; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.controller.api.AdminSettingsPerfHarness.Measure; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.dto.AdminUserSummary; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.TeamService; + +/** Admin roster issues a constant query count regardless of size (H2). */ +@DataJpaTest +class AdminSettingsQueryPerfTest { + + @Autowired private UserRepository userRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private TeamRepository teamRepository; + @Autowired private TeamMembershipRepository teamMembershipRepository; + @Autowired private ResourceGrantRepository resourceGrantRepository; + @Autowired private EntityManagerFactory emf; + + @PersistenceContext private EntityManager em; + + private AdminSettingsPerfHarness harness() { + return new AdminSettingsPerfHarness( + userRepository, + sessionRepository, + teamRepository, + teamMembershipRepository, + resourceGrantRepository, + em, + emf); + } + + @Test + void queryCountDoesNotScaleWithUsers() { + AdminSettingsPerfHarness harness = harness(); + ProprietaryUIDataController controller = harness.buildController(); + Authentication admin = harness.adminAuth(); + + Measure small = harness.seedAndMeasure(controller, admin, 150); + Measure large = harness.seedAndMeasure(controller, admin, 750); + + System.out.printf( + "%n[admin-settings scaling] N=%d -> %d statements, %d updates, %d ms%n", + small.users(), small.statements(), small.updates(), small.millis()); + System.out.printf( + "[admin-settings scaling] N=%d -> %d statements, %d updates, %d ms%n", + large.users(), large.statements(), large.updates(), large.millis()); + long delta = large.statements() - small.statements(); + System.out.printf( + "[admin-settings scaling] +%d users cost +%d statements%n", + large.users() - small.users(), delta); + + assertTrue( + delta <= 40, + "admin-settings issues per-user queries: adding " + + (large.users() - small.users()) + + " users added " + + delta + + " SQL statements (expected <= 40). The roster endpoint still scales O(N)."); + assertEquals( + 0, + large.updates(), + "admin-settings performed " + large.updates() + " row UPDATEs during a read (GET)"); + } + + @Test + void headlineBenchmark() { + int n = Integer.getInteger("adminBenchUsers", 2000); + AdminSettingsPerfHarness harness = harness(); + ProprietaryUIDataController controller = harness.buildController(); + + Measure m = harness.seedAndMeasure(controller, harness.adminAuth(), n); + System.out.printf( + "%n==== admin-settings headline (N=%d users) ====%n" + + " SQL statements : %d%n" + + " row UPDATEs : %d%n" + + " row INSERTs : %d%n" + + " wall-clock : %d ms%n" + + " statements/user: %.2f%n" + + "==============================================%n", + m.users(), + m.statements(), + m.updates(), + m.inserts(), + m.millis(), + (double) m.statements() / n); + + assertEquals(n, m.users(), "roster should return every seeded (non-internal) user"); + } + + @Test + void rosterExcludesInternalAccountsAndMasksSecrets() { + AdminSettingsPerfHarness harness = harness(); + ProprietaryUIDataController controller = harness.buildController(); + harness.wipe(); + + Team acme = new Team(); + acme.setName("acme"); + Team internal = new Team(); + internal.setName(TeamService.INTERNAL_TEAM_NAME); + List savedTeams = harness.teams().saveAll(List.of(acme, internal)); + harness.em().flush(); + + User adminUser = + harness.mkUser("admin", savedTeams.get(0), Role.ADMIN.getRoleId(), Map.of()); + User mfaUser = + harness.mkUser( + "mfa-user", + savedTeams.get(0), + Role.USER.getRoleId(), + Map.of("mfaSecret", "TOPSECRET", "language", "fr")); + User apiUser = + harness.mkUser( + "internal-api", + savedTeams.get(0), + Role.INTERNAL_API_USER.getRoleId(), + Map.of()); + User internalTeamUser = + harness.mkUser("internal-team", savedTeams.get(1), Role.USER.getRoleId(), Map.of()); + harness.users().saveAll(List.of(adminUser, mfaUser, apiUser, internalTeamUser)); + harness.em().flush(); + harness.em().clear(); + + Authentication auth = mock(Authentication.class); + lenient().when(auth.getName()).thenReturn("admin"); + ProprietaryUIDataController.AdminSettingsData data = + controller.getAdminSettingsData(auth).getBody(); + + Set usernames = + data.getUsers().stream() + .map(AdminUserSummary::getUsername) + .collect(Collectors.toSet()); + assertTrue(usernames.contains("admin")); + assertTrue(usernames.contains("mfa-user")); + assertFalse(usernames.contains("internal-api"), "internal-api user must be excluded"); + assertFalse(usernames.contains("internal-team"), "internal-team user must be excluded"); + assertEquals(2, data.getTotalUsers()); + + Map mfaSettings = data.getUserSettings().get("mfa-user"); + assertEquals("********", mfaSettings.get("mfaSecret"), "mfaSecret must be masked"); + assertEquals("fr", mfaSettings.get("language"), "non-secret settings preserved"); + + AdminUserSummary adminSummary = + data.getUsers().stream() + .filter(u -> "admin".equals(u.getUsername())) + .findFirst() + .orElseThrow(); + assertTrue(adminSummary.isPortalAccess(), "admin should have portal access"); + } + + @SpringBootConfiguration + @EntityScan( + basePackages = { + "stirling.software.proprietary.security.model", + "stirling.software.proprietary.model", + "stirling.software.proprietary.access.model" + }) + @EnableJpaRepositories( + basePackages = { + "stirling.software.proprietary.security.database.repository", + "stirling.software.proprietary.security.repository", + "stirling.software.proprietary.access.repository" + }) + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPostgresTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPostgresTest.java new file mode 100644 index 0000000000..11d3e7184c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPostgresTest.java @@ -0,0 +1,109 @@ +package stirling.software.proprietary.controller.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceContext; + +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.controller.api.AdminSettingsPerfHarness.Measure; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamRepository; + +/** Admin-roster queries + index DDL on real Postgres (skipped without Docker). */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers(disabledWithoutDocker = true) +class AdminSettingsQueryPostgresTest { + + @Container + static PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.properties.hibernate.default_batch_fetch_size", () -> "100"); + } + + @Autowired private UserRepository userRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private TeamRepository teamRepository; + @Autowired private TeamMembershipRepository teamMembershipRepository; + @Autowired private ResourceGrantRepository resourceGrantRepository; + @Autowired private EntityManagerFactory emf; + + @PersistenceContext private EntityManager em; + + private AdminSettingsPerfHarness harness() { + return new AdminSettingsPerfHarness( + userRepository, + sessionRepository, + teamRepository, + teamMembershipRepository, + resourceGrantRepository, + em, + emf); + } + + @Test + void newRosterQueriesRunOnPostgresWithConstantScaling() { + AdminSettingsPerfHarness harness = harness(); + ProprietaryUIDataController controller = harness.buildController(); + Authentication admin = harness.adminAuth(); + + Measure small = harness.seedAndMeasure(controller, admin, 100); + Measure large = harness.seedAndMeasure(controller, admin, 400); + + System.out.printf( + "%n[admin-settings postgres] N=%d -> %d statements, %d updates, %d ms%n", + small.users(), small.statements(), small.updates(), small.millis()); + System.out.printf( + "[admin-settings postgres] N=%d -> %d statements, %d updates, %d ms%n", + large.users(), large.statements(), large.updates(), large.millis()); + + assertEquals(400, large.users(), "roster returns every seeded user on Postgres"); + assertTrue( + large.statements() - small.statements() <= 40, + "roster must not scale per-user on Postgres (delta=" + + (large.statements() - small.statements()) + + ")"); + assertEquals(0, large.updates(), "no writes during the GET on Postgres"); + } + + @SpringBootConfiguration + @EntityScan( + basePackages = { + "stirling.software.proprietary.security.model", + "stirling.software.proprietary.model", + "stirling.software.proprietary.access.model" + }) + @EnableJpaRepositories( + basePackages = { + "stirling.software.proprietary.security.database.repository", + "stirling.software.proprietary.security.repository", + "stirling.software.proprietary.access.repository" + }) + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java index 585edc6e66..58b8972a45 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java @@ -141,7 +141,8 @@ class ProprietaryUIDataControllerMoreTest { void singleAdminFirstLogin() { User admin = normalUser(1L, "admin"); admin.setFirstLogin(true); - when(userRepository.findAll()).thenReturn(List.of(admin)); + when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())) + .thenReturn(1L); when(userRepository.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(admin)); ResponseEntity response = controller.getLoginData(); @@ -154,7 +155,8 @@ class ProprietaryUIDataControllerMoreTest { @Test @DisplayName("does not flag setup when a normal user exists") void normalUserNoSetup() { - when(userRepository.findAll()).thenReturn(List.of(normalUser(1L, "bob"))); + when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())) + .thenReturn(1L); ResponseEntity response = controller.getLoginData(); @@ -252,11 +254,9 @@ class ProprietaryUIDataControllerMoreTest { @DisplayName("aggregates users, teams and license limits") void aggregates() { User user = normalUser(1L, "bob"); - when(userRepository.findAllWithTeam()) + when(userRepository.findAllWithTeamAndAuthorities()) .thenReturn(new java.util.ArrayList<>(List.of(user))); when(sessionPersistentRegistry.getMaxInactiveInterval()).thenReturn(3600); - when(sessionPersistentRegistry.findLatestSession("bob")).thenReturn(Optional.empty()); - when(userRepository.findByIdWithSettings(1L)).thenReturn(Optional.of(user)); when(teamRepository.findAll()).thenReturn(List.of()); when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(10); @@ -310,7 +310,7 @@ class ProprietaryUIDataControllerMoreTest { team.setName("Engineering"); when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); when(userRepository.findAllByTeamId(5L)).thenReturn(List.of()); - when(userRepository.findAllWithTeam()).thenReturn(List.of()); + when(userRepository.findAllWithTeamAndAuthorities()).thenReturn(List.of()); when(sessionRepository.findLatestSessionByTeamId(5L)) .thenReturn(Collections.emptyList()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java index 0b01c9bcf5..297d83583d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java @@ -3,7 +3,6 @@ package stirling.software.proprietary.controller.api; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -93,7 +92,7 @@ class ProprietaryUIDataControllerTest { @Test void loginDataFlagsFirstTimeSetupWhenNoUsers() { - when(userRepository.findAll()).thenReturn(Collections.emptyList()); + when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())).thenReturn(0L); ResponseEntity response = controller.getLoginData();