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; *
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