diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index a9369519e5..b36234945b 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -1515,6 +1515,18 @@ public class ApplicationProperties { private boolean enabled; @ToString.Exclude private String key; private int maxUsers; + + /** + * Servers purchased, and the users each one grants. Both come from licence metadata and are + * presentation only: {@code maxUsers} is the limit that is actually enforced. They exist so + * the UI can say "2 servers, 100 users each" rather than a bare 200, and so the + * add-capacity flow knows what a single additional server buys. Zero means the licence + * predates the cap and carries no server breakdown. + */ + private int serverQuantity; + + private int userBlockSize; + private ProFeatures proFeatures = new ProFeatures(); private EnterpriseFeatures enterpriseFeatures = new EnterpriseFeatures(); 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 02fa1e0b25..b5a8eb2303 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,6 +3,7 @@ package stirling.software.proprietary.controller.api; import static stirling.software.common.util.ProviderUtils.validateProvider; import java.time.Instant; +import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -38,6 +39,7 @@ import stirling.software.proprietary.audit.AuditLevel; 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.model.dto.TeamWithUserCountDTO; import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.security.config.EnterpriseEndpoint; @@ -46,6 +48,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.dto.AdminUserSummary; +import stirling.software.proprietary.security.repository.InviteTokenRepository; import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; @@ -78,6 +81,7 @@ public class ProprietaryUIDataController { private final MfaService mfaService; private final LoginAttemptService loginAttemptService; private final ResourceAccessService resourceAccessService; + private final InviteTokenRepository inviteTokenRepository; public ProprietaryUIDataController( ApplicationProperties applicationProperties, @@ -94,7 +98,8 @@ public class ProprietaryUIDataController { PersistentAuditEventRepository auditRepository, MfaService mfaService, LoginAttemptService loginAttemptService, - ResourceAccessService resourceAccessService) { + ResourceAccessService resourceAccessService, + InviteTokenRepository inviteTokenRepository) { this.applicationProperties = applicationProperties; this.auditConfig = auditConfig; this.sessionPersistentRegistry = sessionPersistentRegistry; @@ -110,6 +115,7 @@ public class ProprietaryUIDataController { this.mfaService = mfaService; this.loginAttemptService = loginAttemptService; this.resourceAccessService = resourceAccessService; + this.inviteTokenRepository = inviteTokenRepository; } /** @@ -342,8 +348,10 @@ public class ProprietaryUIDataController { int maxAllowedUsers = licenseSettingsService.calculateMaxAllowedUsers(); long availableSlots = licenseSettingsService.getAvailableUserSlots(); int grandfatheredCount = licenseSettingsService.getDisplayGrandfatheredCount(); - int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers(); + UserLicenseSettings licenseSettings = licenseSettingsService.getSettings(); + int licenseMaxUsers = licenseSettings.getLicenseMaxUsers(); boolean premiumEnabled = applicationProperties.getPremium().isEnabled(); + long pendingInvites = inviteTokenRepository.countActiveInvites(LocalDateTime.now()); // Resolve portal access for the whole roster. The teamLead display flag counts a // LEADER membership on any team (mirrors /me), but the portal default policy only @@ -386,6 +394,9 @@ public class ProprietaryUIDataController { data.setAvailableSlots(availableSlots); data.setGrandfatheredUserCount(grandfatheredCount); data.setLicenseMaxUsers(licenseMaxUsers); + data.setServerQuantity(licenseSettings.getServerQuantity()); + data.setUserBlockSize(licenseSettings.getUserBlockSize()); + data.setPendingInvites(pendingInvites); data.setPremiumEnabled(premiumEnabled); data.setMailEnabled(applicationProperties.getMail().isEnabled()); // Email invites need the invites toggle AND SMTP on; matches the inviteUsers precondition. @@ -660,6 +671,23 @@ public class ProprietaryUIDataController { private long availableSlots; private int grandfatheredUserCount; private int licenseMaxUsers; + + /** + * Capacity breakdown for the People page: how many servers the licence covers and how many + * users each grants. Both 0 on a licence issued before the cap, in which case the UI has + * only {@code maxAllowedUsers} to show. + */ + private int serverQuantity; + + private int userBlockSize; + + /** + * Invites issued but not yet redeemed. They hold a slot the same way a disabled account + * does, so the capacity UI can show what is consuming the limit and offer a way to reclaim + * it before asking anyone to pay. + */ + private long pendingInvites; + private boolean premiumEnabled; private boolean mailEnabled; private boolean emailInvitesEnabled; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java index bb7f52142a..b3385de2ec 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java @@ -49,6 +49,17 @@ public class UserLicenseSettings implements Serializable { @Column(name = "license_max_users", nullable = false) private int licenseMaxUsers = 0; + /** + * Servers purchased and users granted per server, from licence metadata. Presentation only, so + * the capacity UI can say "2 servers, 100 users each" between the weekly Keygen refreshes. + * {@code licenseMaxUsers} stays the enforced limit. Zero on any licence issued before the cap. + */ + @Column(name = "server_quantity", nullable = false) + private int serverQuantity = 0; + + @Column(name = "user_block_size", nullable = false) + private int userBlockSize = 0; + /** * Random salt used when generating signatures. Makes it harder to recompute the signature when * manually editing the table. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 5d68bc99e0..b2712628e1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -142,7 +142,8 @@ public class InitialSecuritySetup { .password(initialPassword) .team(team) .role(Role.ADMIN.getRoleId()) - .firstLogin(false); + .firstLogin(false) + .bypassUserLimit(true); userService.saveUserCore(builder.build()); log.info("Admin user created: {}", initialUsername); } else { @@ -162,7 +163,8 @@ public class InitialSecuritySetup { .password(defaultPassword) .team(team) .role(Role.ADMIN.getRoleId()) - .firstLogin(true); + .firstLogin(true) + .bypassUserLimit(true); userService.saveUserCore(builder.build()); log.info("Default admin user created: {}", defaultUsername); } @@ -178,7 +180,11 @@ public class InitialSecuritySetup { .password(UUID.randomUUID().toString()) .team(team) .role(Role.INTERNAL_API_USER.getRoleId()) - .firstLogin(false); + .firstLogin(false) + // Excluded from getTotalUsersCount(), so it must not be charged against + // the limit either; an installation at its cap still needs this + // account. + .bypassUserLimit(true); userService.saveUserCore(builder.build()); userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId()); log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java index bc68a64f6e..39d7b72f77 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java @@ -64,6 +64,25 @@ public class KeygenLicenseVerifier { public LicenseContext() {} } + /** + * Records the server/block breakdown behind {@code users}. Presentation only: {@code maxUsers} + * remains the enforced limit and is set by each caller. Absent on any licence issued before the + * cap, and on enterprise licences, which carry no server count. + * + *

Called from all three verification paths (certificate, JWT policy, live API) so the three + * cannot drift; {@code metadataObj} is whichever node carries the licence metadata there. + */ + private void applyCapacityBreakdown(JsonNode metadataObj) { + int serverQuantity = 0; + int userBlockSize = 0; + if (metadataObj != null && !metadataObj.isMissingNode() && metadataObj.isObject()) { + serverQuantity = Math.max(0, metadataObj.path("server_quantity").asInt(0)); + userBlockSize = Math.max(0, metadataObj.path("user_block_size").asInt(0)); + } + applicationProperties.getPremium().setServerQuantity(serverQuantity); + applicationProperties.getPremium().setUserBlockSize(userBlockSize); + } + public License verifyLicense(String licenseKeyOrCert) { if (!applicationProperties.getPremium().isEnabled()) { return License.NORMAL; @@ -284,6 +303,7 @@ public class KeygenLicenseVerifier { } applicationProperties.getPremium().setMaxUsers(users); + applyCapacityBreakdown(metadataObj); } // Check license status if available @@ -480,6 +500,7 @@ public class KeygenLicenseVerifier { } applicationProperties.getPremium().setMaxUsers(users); + applyCapacityBreakdown(policyObj.path("metadata")); } return true; @@ -666,6 +687,7 @@ public class KeygenLicenseVerifier { } applicationProperties.getPremium().setMaxUsers(users); + applyCapacityBreakdown(jsonResponse.path("data").path("attributes").path("metadata")); log.debug(applicationProperties.toString()); } else { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java index 9e05e9ebbc..04fbd9d3b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -237,6 +237,10 @@ public class AdminLicenseController { ApplicationProperties.Premium premium = applicationProperties.getPremium(); response.put("enabled", premium.isEnabled()); response.put("maxUsers", premium.getMaxUsers()); + // Presentation only, so the plan page can say "2 servers, 100 users each" rather than a + // bare 200. Both are 0 on a licence issued before the cap. + response.put("serverQuantity", premium.getServerQuantity()); + response.put("userBlockSize", premium.getUserBlockSize()); response.put("hasKey", premium.getKey() != null && !premium.getKey().trim().isEmpty()); // Include license key for upgrades (admin-only endpoint) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java index 641216e269..d1b494583c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java @@ -450,6 +450,25 @@ public class InviteLinkController { return invalidInviteResponse(); } + // Re-check the licence limit at redemption. The check when the link was generated + // counted the invites outstanding at that moment; users can have been added since, and + // a link issued while slots were free must not be able to create the user over the cap. + if (userLicenseSettingsService.wouldExceedLimit(1)) { + int maxUsers = userLicenseSettingsService.calculateMaxAllowedUsers(); + log.warn( + "Invite redemption refused for {}: licence limit of {} users reached", + effectiveEmail, + maxUsers); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body( + Map.of( + "error", + "This workspace has reached its limit of " + + maxUsers + + " users. Ask an administrator to add capacity," + + " then use this link again.")); + } + // Create the user account SaveUserRequest.Builder builder = SaveUserRequest.builder() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/UserLimitExceededException.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/UserLimitExceededException.java new file mode 100644 index 0000000000..ff9b71cb41 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/UserLimitExceededException.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.security.model.exception; + +/** + * Thrown when creating a user would take the installation past the licence's user limit. + * + *

This is the last line of defence inside {@code UserService.saveUserCore}. Callers that can + * present a useful message are expected to check {@code + * UserLicenseSettingsService.wouldExceedLimit} first and fail with their own response; reaching + * this exception means a code path was added that forgot to. + * + *

Unchecked so that adding the guard does not change the signature of every method between a + * controller and {@code saveUserCore}. + */ +public class UserLimitExceededException extends RuntimeException { + + private final long currentUsers; + private final int maxAllowedUsers; + + public UserLimitExceededException(long currentUsers, int maxAllowedUsers) { + super( + "Maximum number of users reached. Allowed: " + + maxAllowedUsers + + ", current: " + + currentUsers); + this.currentUsers = currentUsers; + this.maxAllowedUsers = maxAllowedUsers; + } + + public long getCurrentUsers() { + return currentUsers; + } + + public int getMaxAllowedUsers() { + return maxAllowedUsers; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java index b20cf88dd7..b7facb2445 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java @@ -28,6 +28,7 @@ import stirling.software.proprietary.security.model.AuthenticationType; *

  • mfaEnabled: false *
  • mfaSecret: null *
  • mfaLastUsedStep: null + *
  • bypassUserLimit: false * */ @Getter @@ -47,4 +48,15 @@ public class SaveUserRequest { @Builder.Default private final boolean mfaEnabled = false; @Builder.Default private final String mfaSecret = null; @Builder.Default private final Long mfaLastUsedStep = null; + + /** + * Skips the licence user-limit guard in {@code UserService.saveUserCore}. Reserved for accounts + * the installation cannot function without: the bootstrap admin and {@code INTERNAL_API_USER}, + * both created by {@code InitialSecuritySetup} before anyone can log in. The internal API user + * in particular is excluded from {@code getTotalUsersCount()}, so counting its creation against + * the limit would strand an installation that is already at its cap. + * + *

    Never set this on a path that creates a real person's account. + */ + @Builder.Default private final boolean bypassUserLimit = false; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index e08dd52d07..a922ec3f06 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -17,6 +17,7 @@ import java.util.UUID; import java.util.function.Supplier; import org.slf4j.MDC; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -51,9 +52,11 @@ import stirling.software.proprietary.security.database.repository.UserRepository import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.exception.UserLimitExceededException; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.service.UserLicenseSettingsService; import stirling.software.proprietary.storage.model.FileShare; import stirling.software.proprietary.storage.model.StorageCleanupEntry; import stirling.software.proprietary.storage.model.StoredFile; @@ -97,6 +100,12 @@ public class UserService implements UserServiceInterface { private final TeamMembershipService teamMembershipService; private final ApiKeyAuthenticationService apiKeyAuthenticationService; + // ObjectProvider breaks the cycle: UserLicenseSettingsService injects this service to count + // users, and saveUserCore needs it back to enforce the limit. Same pattern that service already + // uses for LicenseKeyChecker. Absent outside the security profile, in which case there is no + // licence to enforce. + private final ObjectProvider licenseSettingsService; + @Transactional public void processSSOPostLogin( String username, @@ -499,6 +508,8 @@ public class UserService implements UserServiceInterface { throw new IllegalArgumentException(getInvalidUsernameMessage()); } + enforceUserLimit(request); + User user = new User(); user.setUsername(request.getUsername()); @@ -561,6 +572,31 @@ public class UserService implements UserServiceInterface { return user; } + /** + * Last line of defence on the licence user limit. Callers that can render a useful message + * check {@code wouldExceedLimit} first and fail with their own response; this only fires when a + * creation path was added without one. + */ + private void enforceUserLimit(SaveUserRequest request) { + if (request.isBypassUserLimit()) { + return; + } + UserLicenseSettingsService settings = licenseSettingsService.getIfAvailable(); + if (settings == null || !settings.wouldExceedLimit(1)) { + return; + } + long current = getTotalUsersCount(); + int max = settings.calculateMaxAllowedUsers(); + log.warn( + "Refusing to create user {}: would exceed the licence limit of {} ({} in use). If" + + " this is a legitimate path it should check wouldExceedLimit() first and" + + " return a useful error.", + request.getUsername(), + max, + current); + throw new UserLimitExceededException(current, max); + } + public boolean isUsernameValid(String username) { // Checks whether the simple username is formatted correctly // Regular expression for user name: Min. 3 characters, max. 50 characters diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index 085a9ffcf1..bdd45aff1d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -156,14 +156,26 @@ public class UserLicenseSettingsService { UserLicenseSettings settings = getOrCreateSettings(); int licenseMaxUsers = 0; + int serverQuantity = 0; + int userBlockSize = 0; if (hasPaidLicense()) { licenseMaxUsers = applicationProperties.getPremium().getMaxUsers(); + serverQuantity = applicationProperties.getPremium().getServerQuantity(); + userBlockSize = applicationProperties.getPremium().getUserBlockSize(); } - if (settings.getLicenseMaxUsers() != licenseMaxUsers) { + if (settings.getLicenseMaxUsers() != licenseMaxUsers + || settings.getServerQuantity() != serverQuantity + || settings.getUserBlockSize() != userBlockSize) { settings.setLicenseMaxUsers(licenseMaxUsers); + settings.setServerQuantity(serverQuantity); + settings.setUserBlockSize(userBlockSize); settingsRepository.save(settings); - log.info("Updated license max users to: {}", licenseMaxUsers); + log.info( + "Updated license capacity: maxUsers={}, servers={}, usersPerServer={}", + licenseMaxUsers, + serverQuantity, + userBlockSize); } } 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 index 35b9e354de..8455e77ffe 100644 --- 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 @@ -39,6 +39,7 @@ 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.InviteTokenRepository; import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.DatabaseServiceInterface; @@ -222,7 +223,8 @@ class AdminSettingsPerfHarness { mock(PersistentAuditEventRepository.class), mock(MfaService.class), loginAttemptService, - resourceAccessService); + resourceAccessService, + mock(InviteTokenRepository.class)); } Authentication adminAuth() { 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 6b469f7ba2..13f1e1f049 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 @@ -68,6 +68,10 @@ class ProprietaryUIDataControllerMoreTest { @Mock private LoginAttemptService loginAttemptService; @Mock private ResourceAccessService resourceAccessService; + @Mock + private stirling.software.proprietary.security.repository.InviteTokenRepository + inviteTokenRepository; + private ApplicationProperties applicationProperties; private AuditConfigurationProperties auditConfig; private ObjectMapper objectMapper; @@ -100,7 +104,8 @@ class ProprietaryUIDataControllerMoreTest { auditRepository, mfaService, loginAttemptService, - resourceAccessService); + resourceAccessService, + inviteTokenRepository); } private static User normalUser(Long id, String username) { 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 297d83583d..a39462a370 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 @@ -53,6 +53,10 @@ class ProprietaryUIDataControllerTest { @Mock private LoginAttemptService loginAttemptService; @Mock private ResourceAccessService resourceAccessService; + @Mock + private stirling.software.proprietary.security.repository.InviteTokenRepository + inviteTokenRepository; + private ApplicationProperties applicationProperties; private AuditConfigurationProperties auditConfig; private ObjectMapper objectMapper; @@ -87,7 +91,8 @@ class ProprietaryUIDataControllerTest { auditRepository, mfaService, loginAttemptService, - resourceAccessService); + resourceAccessService, + inviteTokenRepository); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerMoreTest.java index beca696e4c..d2e0341c9c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerMoreTest.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.controller.api; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -303,5 +304,24 @@ class InviteLinkControllerMoreTest { verify(userService).saveUserCore(any()); verify(inviteTokenRepository).save(invite); } + + @Test + @DisplayName("refuses redemption once the licence limit is reached") + void refusesAtLicenceLimit() throws Exception { + InviteToken invite = validInvite("atcap"); + invite.setEmail("late@ex.com"); + when(inviteTokenRepository.findByToken("atcap")).thenReturn(Optional.of(invite)); + when(userService.usernameExistsIgnoreCase("late@ex.com")).thenReturn(false); + // The link was minted while slots were free; the workspace has filled up since. + when(userLicenseSettingsService.wouldExceedLimit(1)).thenReturn(true); + when(userLicenseSettingsService.calculateMaxAllowedUsers()).thenReturn(100); + + mockMvc.perform(post("/api/v1/invite/accept/atcap").param("password", "secret123")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value(containsString("limit of 100 users"))); + + verify(userService, never()).saveUserCore(any()); + verify(inviteTokenRepository, never()).save(invite); + } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index 4b49b4c4d2..4d5ed65f78 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -32,6 +32,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.exception.UserLimitExceededException; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.storage.model.FileShare; @@ -73,6 +74,11 @@ class UserServiceTest { @Mock private TeamMembershipService teamMembershipService; @Mock private ApiKeyAuthenticationService apiKeyAuthenticationService; + @Mock + private org.springframework.beans.factory.ObjectProvider< + stirling.software.proprietary.service.UserLicenseSettingsService> + licenseSettingsService; + @Spy @InjectMocks private UserService userService; @Test @@ -159,6 +165,52 @@ class UserServiceTest { assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied"); } + @Test + void saveUserCore_atLimit_refusesAndDoesNotPersist() { + stirling.software.proprietary.service.UserLicenseSettingsService settings = + mock(stirling.software.proprietary.service.UserLicenseSettingsService.class); + when(licenseSettingsService.getIfAvailable()).thenReturn(settings); + when(settings.wouldExceedLimit(1)).thenReturn(true); + when(settings.calculateMaxAllowedUsers()).thenReturn(100); + when(userRepository.count()).thenReturn(100L); + when(userRepository.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) + .thenReturn(Optional.empty()); + + SaveUserRequest request = SaveUserRequest.builder().username("oneTooMany").build(); + + UserLimitExceededException thrown = + assertThrows( + UserLimitExceededException.class, () -> userService.saveUserCore(request)); + + assertEquals(100, thrown.getMaxAllowedUsers()); + verify(userRepository, never()).save(any(User.class)); + verifyNoInteractions(databaseService); + } + + @Test + void saveUserCore_bypassUserLimit_createsInternalAccountAtLimit() + throws SQLException, UnsupportedProviderException { + Team internalTeam = new Team(); + internalTeam.setName("Internal"); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // The internal API user is excluded from the count, so an installation sitting at its cap + // must still be able to create it. No licence lookup should happen at all. + SaveUserRequest request = + SaveUserRequest.builder() + .username(Role.INTERNAL_API_USER.getRoleId()) + .team(internalTeam) + .bypassUserLimit(true) + .build(); + + User saved = userService.saveUserCore(request); + + assertEquals(Role.INTERNAL_API_USER.getRoleId(), saved.getUsername()); + verify(userRepository).save(any(User.class)); + verifyNoInteractions(licenseSettingsService); + } + @Test void processSSOPostLogin_autoCreatesUserWhenMissing() throws IllegalArgumentException, SQLException, UnsupportedProviderException {