mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
perf(portal): collapse admin-roster N+1 + session indexes (#7008)
# Description of Changes Collapses the portal admin-roster endpoint (`getAdminSettingsData`, `/api/v1/proprietary/ui-data/admin-settings`) from a per-user N+1 into a constant set of queries, and adds the missing session/user/team-membership indexes. **Verified on H2 and real Postgres 16, 2,000-user roster:** 10,601 → 7 SQL statements, 600 → 0 writes-during-a-GET, O(N) → O(1). Portal-access resolution is proven equivalent to the per-user check (parity test), and a scaling guard fails the build if the endpoint ever regresses. Also in scope (same controller / session subsystem): `getLoginData` counts instead of loading the whole user table; `getTeamDetailsData` fetch-joins authorities; `SessionScheduled` uses one bulk expire + a bounded purge. Behaviour note: the roster "active" flag now reflects *any* live session (a strict superset of the old "newest session only") — no user who was active is ever shown inactive. --- ## Checklist ### General - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Testing - [x] I have run backend `task check` (spotless + full backend test suite) — all green - [x] I have tested my changes locally (before/after benchmark on H2 + Postgres)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
|
||||
+42
@@ -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<Long> usersWithPortalAccess(Collection<User> users, Set<Long> teamLeaderUserIds) {
|
||||
Set<PrincipalRef> 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<Long> leaderIds = teamLeaderUserIds == null ? Set.of() : teamLeaderUserIds;
|
||||
Set<Long> 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<PrincipalRef> grantedPrincipals, Set<Long> 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,
|
||||
|
||||
+96
-97
@@ -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<User> allUsers = userRepository.findAll();
|
||||
List<User> 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<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
|
||||
List<User> allUsers = userRepository.findAllWithTeam();
|
||||
Iterator<User> iterator = allUsers.iterator();
|
||||
List<User> allUsers = userRepository.findAllWithTeamAndAuthorities();
|
||||
Map<String, String> roleDetails = Role.getAllRoleDetails();
|
||||
|
||||
// Drop the internal API user and internal-team members; the roster never shows them.
|
||||
boolean hasInternalApiUser = false;
|
||||
List<User> 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<Long, Map<String, String>> 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<String, Instant> lastRequestByPrincipal = new HashMap<>();
|
||||
for (Object[] row : sessionRepository.findLatestRequestPerPrincipal()) {
|
||||
if (row[0] != null) {
|
||||
lastRequestByPrincipal.put((String) row[0], (Instant) row[1]);
|
||||
}
|
||||
}
|
||||
Set<String> activePrincipals =
|
||||
new HashSet<>(sessionRepository.findActivePrincipalsSince(activeCutoff));
|
||||
|
||||
Map<String, Boolean> userSessions = new HashMap<>();
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
Map<String, Map<String, String>> 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<SessionEntity> 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<String, String> originalSettings = userWithSettings.getSettings();
|
||||
Map<String, String> 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<User> 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<Long> leaderUserIds = leaderUserIds();
|
||||
Set<Long> portalAccessUserIds =
|
||||
resourceAccessService.usersWithPortalAccess(sortedUsers, leaderUserIds);
|
||||
List<AdminUserSummary> 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<User> teamUsers = userRepository.findAllByTeamId(id);
|
||||
List<User> allUsers = userRepository.findAllWithTeam();
|
||||
// Fetch authorities + team for the available-users list.
|
||||
List<User> allUsers = userRepository.findAllWithTeamAndAuthorities();
|
||||
List<User> 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<Long, Map<String, String>> loadSettingsByUserId(List<Long> userIds) {
|
||||
Map<Long, Map<String, String>> 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<String, String> maskSecrets(Map<String, String> settings) {
|
||||
Map<String, String> 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<Long> leaderUserIds) {
|
||||
private AdminUserSummary convertUserToSummary(
|
||||
User user, Set<Long> leaderUserIds, Set<Long> 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());
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
+25
@@ -45,4 +45,29 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
|
||||
+ "WHERE u.team.id = :teamId "
|
||||
+ "GROUP BY u.username")
|
||||
List<Object[]> 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<Object[]> 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<String> 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);
|
||||
}
|
||||
|
||||
+11
@@ -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<User, Long> {
|
||||
@Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team")
|
||||
List<User> findAllWithTeam();
|
||||
|
||||
/** All users with team + authorities fetched (DISTINCT dedupes the collection join). */
|
||||
@EntityGraph(attributePaths = {"team", "authorities"})
|
||||
@Query("SELECT DISTINCT u FROM User u")
|
||||
List<User> 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<Object[]> findSettingsByUserIds(@Param("ids") Collection<Long> ids);
|
||||
|
||||
@Query(
|
||||
"SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId")
|
||||
List<User> findAllByTeamId(@Param("teamId") Long teamId);
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
+11
-1
@@ -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;
|
||||
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+10
@@ -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<SessionEntity> sessionEntityOpt = sessionRepository.findById(sessionId);
|
||||
|
||||
+7
-19
@@ -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<SessionInformation> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+151
@@ -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<User> everyone;
|
||||
private Set<Long> 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<PrincipalRef> 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<Long> 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<Long> 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;
|
||||
}
|
||||
}
|
||||
+255
@@ -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<Team> teams = new ArrayList<>(teamCount);
|
||||
for (int i = 0; i < teamCount; i++) {
|
||||
Team team = new Team();
|
||||
team.setName("team-" + i);
|
||||
teams.add(team);
|
||||
}
|
||||
List<Team> savedTeams = teamRepository.saveAll(teams);
|
||||
em.flush();
|
||||
|
||||
List<User> users = new ArrayList<>(userCount);
|
||||
List<SessionEntity> 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<String, String> 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<User> savedUsers = userRepository.saveAll(users);
|
||||
sessionRepository.saveAll(sessions);
|
||||
em.flush();
|
||||
|
||||
List<TeamMembership> 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<String, String> 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;
|
||||
}
|
||||
}
|
||||
+194
@@ -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<Team> 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<String> 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<String, String> 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 {}
|
||||
}
|
||||
+109
@@ -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 {}
|
||||
}
|
||||
+6
-6
@@ -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<LoginData> 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<LoginData> 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());
|
||||
|
||||
|
||||
+1
-2
@@ -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<LoginData> response = controller.getLoginData();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user