grandfather users (#4984)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Dario Ghunney Ware <[email protected]>
This commit is contained in:
Anthony Stirling
2025-11-25 21:23:32 +00:00
committed by GitHub
co-authored by ConnorYoh Dario Ghunney Ware
parent daf749e6be
commit e6db57e031
22 changed files with 564 additions and 71 deletions
@@ -397,7 +397,8 @@ public class ProprietaryUIDataController {
Map<Long, Date> teamLastRequest = new HashMap<>();
for (Object[] result : teamActivities) {
Long teamId = (Long) result[0];
Date lastActivity = (Date) result[1];
Instant instant = (Instant) result[1];
Date lastActivity = instant != null ? Date.from(instant) : null;
teamLastRequest.put(teamId, lastActivity);
}
@@ -441,7 +442,8 @@ public class ProprietaryUIDataController {
Map<String, Date> userLastRequest = new HashMap<>();
for (Object[] result : userSessions) {
String username = (String) result[0];
Date lastRequest = (Date) result[1];
Instant instant = (Instant) result[1];
Date lastRequest = instant != null ? Date.from(instant) : null;
userLastRequest.put(username, lastRequest);
}
@@ -238,6 +238,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
path = "errorOAuth=oAuth2AutoCreateDisabled";
} else if (request.getParameter("oAuth2AdminBlockedUser") != null) {
path = "errorOAuth=oAuth2AdminBlockedUser";
} else if (request.getParameter("oAuth2RequiresLicense") != null) {
path = "errorOAuth=oAuth2RequiresLicense";
} else if (request.getParameter("saml2RequiresLicense") != null) {
path = "errorOAuth=saml2RequiresLicense";
} else if (request.getParameter("maxUsersReached") != null) {
path = "errorOAuth=maxUsersReached";
} else if (request.getParameter("userIsDisabled") != null) {
path = "errorOAuth=userIsDisabled";
} else if ((errorMessage = request.getParameter("error")) != null) {
@@ -62,6 +62,7 @@ public class InitialSecuritySetup {
private void initializeUserLicenseSettings() {
licenseSettingsService.initializeGrandfatheredCount();
licenseSettingsService.updateLicenseMaxUsers();
licenseSettingsService.grandfatherExistingOAuthUsers();
}
private void configureJWTSettings() {
@@ -23,7 +23,7 @@ public class PremiumEndpointAspect {
public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable {
if (!runningProOrHigher) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This endpoint requires a Pro or higher license");
HttpStatus.FORBIDDEN, "This endpoint requires a Server or Enterprise license");
}
return joinPoint.proceed();
}
@@ -84,6 +84,8 @@ public class SecurityConfiguration {
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@@ -103,7 +105,9 @@ public class SecurityConfiguration {
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) {
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@@ -120,10 +124,11 @@ public class SecurityConfiguration {
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
this.licenseSettingsService = licenseSettingsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
public static PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@@ -354,7 +359,8 @@ public class SecurityConfiguration {
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
jwtService,
licenseSettingsService))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
@@ -395,7 +401,8 @@ public class SecurityConfiguration {
loginAttemptService,
securityProperties.getSaml2(),
userService,
jwtService))
jwtService,
licenseSettingsService))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
@@ -32,7 +32,8 @@ public class EEAppConfig {
@Bean(name = "runningProOrHigher")
@Primary
public boolean runningProOrHigher() {
return licenseKeyChecker.getPremiumLicenseEnabledResult() != License.NORMAL;
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
}
@Profile("security")
@@ -30,7 +30,7 @@ public class KeygenLicenseVerifier {
public enum License {
NORMAL,
PRO,
SERVER,
ENTERPRISE
}
@@ -76,7 +76,7 @@ public class KeygenLicenseVerifier {
log.info("Detected certificate-based license. Processing...");
boolean isValid = verifyCertificateLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
} else {
license = License.NORMAL;
}
@@ -84,7 +84,7 @@ public class KeygenLicenseVerifier {
log.info("Detected JWT-style license key. Processing...");
boolean isValid = verifyJWTLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
} else {
license = License.NORMAL;
}
@@ -92,7 +92,7 @@ public class KeygenLicenseVerifier {
log.info("Detected standard license key. Processing...");
boolean isValid = verifyStandardLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
} else {
license = License.NORMAL;
}
@@ -261,10 +261,23 @@ public class KeygenLicenseVerifier {
// Extract metadata
JSONObject metadataObj = attributesObj.optJSONObject("metadata");
if (metadataObj != null) {
int users = metadataObj.optInt("users", 1);
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users", users);
// Check if this is an old license (no planType) with isEnterprise flag
context.isEnterpriseLicense = metadataObj.optBoolean("isEnterprise", false);
// Extract user count - default based on license type
// Old licenses: Only had isEnterprise flag
// New licenses: Have planType field with "server" or "enterprise"
int users = metadataObj.optInt("users", context.isEnterpriseLicense ? 1 : 0);
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
applicationProperties.getPremium().setMaxUsers(users);
}
// Check license status if available
@@ -431,28 +444,31 @@ public class KeygenLicenseVerifier {
}
// Extract max users and isEnterprise from policy or metadata
int users = policyObj.optInt("users", 1);
context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false);
int users = policyObj.optInt("users", -1);
if (users > 0) {
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users", users);
} else {
// Try to get users from metadata if present
if (users == -1) {
// Try to get users from metadata if not at policy level
Object metadataObj = policyObj.opt("metadata");
if (metadataObj instanceof JSONObject metadata) {
users = metadata.optInt("users", 1);
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users (from metadata)", users);
// Check for isEnterprise flag in metadata
context.isEnterpriseLicense = metadata.optBoolean("isEnterprise", false);
context.isEnterpriseLicense =
metadata.optBoolean("isEnterprise", context.isEnterpriseLicense);
users = metadata.optInt("users", context.isEnterpriseLicense ? 1 : 0);
} else {
// Default value
applicationProperties.getPremium().setMaxUsers(1);
log.info("Using default of 1 user for license");
// Default based on license type
users = context.isEnterpriseLicense ? 1 : 0;
}
}
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
applicationProperties.getPremium().setMaxUsers(users);
}
return true;
@@ -584,17 +600,7 @@ public class KeygenLicenseVerifier {
context.maxMachines);
}
// Extract user count, default to 1 if not specified
int users =
jsonResponse
.path("data")
.path("attributes")
.path("metadata")
.path("users")
.asInt(1);
applicationProperties.getPremium().setMaxUsers(users);
// Extract isEnterprise flag
// Extract isEnterprise flag first
context.isEnterpriseLicense =
jsonResponse
.path("data")
@@ -603,6 +609,24 @@ public class KeygenLicenseVerifier {
.path("isEnterprise")
.asBoolean(false);
// Extract user count - default based on license type
int users =
jsonResponse
.path("data")
.path("attributes")
.path("metadata")
.path("users")
.asInt(context.isEnterpriseLicense ? 1 : 0);
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
applicationProperties.getPremium().setMaxUsers(users);
log.debug(applicationProperties.toString());
} else {
@@ -68,8 +68,8 @@ public class LicenseKeyChecker {
premiumEnabledResult = licenseService.verifyLicense(licenseKey);
if (License.ENTERPRISE == premiumEnabledResult) {
log.info("License key is Enterprise.");
} else if (License.PRO == premiumEnabledResult) {
log.info("License key is Pro.");
} else if (License.SERVER == premiumEnabledResult) {
log.info("License key is Server.");
} else {
log.info("License key is invalid, defaulting to non pro license.");
}
@@ -87,6 +87,19 @@ public class UserController {
.body(Map.of("error", "Password is required"));
}
if (licenseSettingsService.wouldExceedLimit(1)) {
long availableSlots = licenseSettingsService.getAvailableUserSlots();
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Maximum number of users reached. Allowed: "
+ maxAllowed
+ ", Available slots: "
+ availableSlots));
}
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
User user =
userService.saveUser(
@@ -39,4 +39,28 @@ public interface UserRepository extends JpaRepository<User, Long> {
long countByTeam(Team team);
List<User> findAllByTeam(Team team);
// OAuth grandfathering queries
long countBySsoProviderIsNotNull();
long countByOauthGrandfatheredTrue();
List<User> findAllBySsoProviderIsNotNull();
/**
* Finds all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
* This catches V1 users who were created via SSO but never signed in (sso_provider is null).
*/
@Query(
"SELECT u FROM User u WHERE u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
List<User> findAllSsoUsers();
/**
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
*/
@Query(
"SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
long countSsoUsers();
}
@@ -71,6 +71,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "sso_provider")
private String ssoProvider;
@Column(name = "oauth_grandfathered")
private Boolean oauthGrandfathered = false;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
private Set<Authority> authorities = new HashSet<>();
@@ -135,4 +138,12 @@ public class User implements UserDetails, Serializable {
public boolean hasPassword() {
return this.password != null && !this.password.isEmpty();
}
public boolean isOauthGrandfathered() {
return oauthGrandfathered != null && oauthGrandfathered;
}
public void setOauthGrandfathered(boolean oauthGrandfathered) {
this.oauthGrandfathered = oauthGrandfathered;
}
}
@@ -50,6 +50,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final UserService userService;
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -66,6 +68,25 @@ public class CustomOAuth2AuthenticationSuccessHandler
username = detailsUser.getUsername();
}
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for OAuth (grandfathered or system has paid license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
response.sendRedirect(
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
// Get the saved request
HttpSession session = request.getSession(false);
String contextPath = request.getContextPath();
@@ -91,7 +112,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
.sendRedirect(request, response, "/logout?userIsDisabled=true");
return;
}
if (userService.usernameExistsIgnoreCase(username)
if (userExists
&& userService.hasPassword(username)
&& (!userService.isAuthenticationTypeByUsername(username, SSO)
|| !userService.isAuthenticationTypeByUsername(username, OAUTH2))
@@ -106,6 +127,10 @@ public class CustomOAuth2AuthenticationSuccessHandler
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
return;
}
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
return;
}
if (principal instanceof OAuth2User oAuth2User) {
// Extract SSO provider information from OAuth2User
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID
@@ -49,6 +49,8 @@ public class CustomSaml2AuthenticationSuccessHandler
private ApplicationProperties.Security.SAML2 saml2Properties;
private UserService userService;
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -63,6 +65,26 @@ public class CustomSaml2AuthenticationSuccessHandler
String username = saml2Principal.name();
log.debug("Authenticated principal found for user: {}", username);
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for SAML (grandfathered or system has paid license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block SAML login
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
HttpSession session = request.getSession(false);
String contextPath = request.getContextPath();
SavedRequest savedRequest =
@@ -96,7 +118,6 @@ public class CustomSaml2AuthenticationSuccessHandler
"Your account has been locked due to too many failed login attempts.");
}
boolean userExists = userService.usernameExistsIgnoreCase(username);
boolean hasPassword = userExists && userService.hasPassword(username);
boolean isSSOUser =
userExists && userService.isAuthenticationTypeByUsername(username, SSO);
@@ -129,6 +150,10 @@ public class CustomSaml2AuthenticationSuccessHandler
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
return;
}
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
return;
}
// Extract SSO provider information from SAML2 assertion
String ssoProviderId = saml2Principal.nameId();
@@ -734,4 +734,48 @@ public class UserService implements UserServiceInterface {
public void saveAll(List<User> users) {
userRepository.saveAll(users);
}
/**
* Counts the number of OAuth/SAML users. Includes users with sso_provider set OR
* authenticationType is sso/oauth2/saml2 (catches V1 users who never signed in).
*
* @return Count of OAuth users
*/
public long countOAuthUsers() {
return userRepository.countSsoUsers();
}
/**
* Counts the number of OAuth users who are grandfathered.
*
* @return Count of grandfathered OAuth users
*/
public long countGrandfatheredOAuthUsers() {
return userRepository.countByOauthGrandfatheredTrue();
}
/**
* Grandfathers all existing OAuth/SAML users. This marks all users with an SSO provider as
* grandfathered, allowing them to keep OAuth access even without a paid license.
*
* @return Number of users updated
*/
@Transactional
public int grandfatherAllOAuthUsers() {
List<User> ssoUsers = userRepository.findAllSsoUsers();
int updated = 0;
for (User user : ssoUsers) {
if (!user.isOauthGrandfathered()) {
user.setOauthGrandfathered(true);
updated++;
}
}
if (updated > 0) {
userRepository.saveAll(ssoUsers);
}
return updated;
}
}
@@ -69,7 +69,7 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
private boolean hasProOrEnterpriseAccess() {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.PRO || license == License.ENTERPRISE;
return license == License.SERVER || license == License.ENTERPRISE;
}
public boolean isEnabled() {
@@ -10,6 +10,7 @@ import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -18,6 +19,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@@ -45,6 +48,7 @@ public class UserLicenseSettingsService {
private final UserLicenseSettingsRepository settingsRepository;
private final UserService userService;
private final ApplicationProperties applicationProperties;
private final ObjectProvider<LicenseKeyChecker> licenseKeyChecker;
/**
* Gets the current user license settings, creating them if they don't exist.
@@ -151,7 +155,7 @@ public class UserLicenseSettingsService {
UserLicenseSettings settings = getOrCreateSettings();
int licenseMaxUsers = 0;
if (applicationProperties.getPremium().isEnabled()) {
if (hasPaidLicense()) {
licenseMaxUsers = applicationProperties.getPremium().getMaxUsers();
}
@@ -162,6 +166,40 @@ public class UserLicenseSettingsService {
}
}
/**
* Grandfathers existing OAuth users on first run. This is a one-time migration that marks all
* existing OAuth/SAML users as grandfathered, allowing them to keep OAuth access even without a
* paid license.
*
* <p>New users created after this migration will NOT be grandfathered and will require a paid
* license to use OAuth.
*/
@Transactional
public void grandfatherExistingOAuthUsers() {
UserLicenseSettings settings = getOrCreateSettings();
// Check if we've already run this migration
if (settings.getId() != null && settings.isGrandfatheringLocked()) {
// Migration should happen at the same time as grandfathering is locked
long oauthUsersCount = userService.countOAuthUsers();
long grandfatheredCount = userService.countGrandfatheredOAuthUsers();
if (oauthUsersCount > 0 && grandfatheredCount == 0) {
// We have OAuth users but none are grandfathered - this is first run after upgrade
int updated = userService.grandfatherAllOAuthUsers();
log.warn(
"OAuth GRANDFATHERING: Marked {} existing OAuth/SAML users as grandfathered. "
+ "They will retain OAuth access even without a paid license. "
+ "New users will require a paid license for OAuth.",
updated);
} else if (grandfatheredCount > 0) {
log.debug(
"OAuth grandfathering already completed: {} users grandfathered",
grandfatheredCount);
}
}
}
/**
* Validates and enforces the integrity of license settings. This ensures that even if someone
* manually modifies the database, the grandfathering rules are still enforced.
@@ -241,12 +279,15 @@ public class UserLicenseSettingsService {
* <p>Logic:
*
* <ul>
* <li>Grandfathered limit = max(5, existing user count at initialization)
* <li>If premium enabled: total limit = grandfathered limit + license maxUsers
* <li>If premium disabled: total limit = grandfathered limit
* <li>Grandfathered limit = max(5, existing user count at V1→V2 migration)
* <li>No license: Uses grandfathered limit only
* <li>SERVER license (maxUsers=0): Unlimited users (Integer.MAX_VALUE)
* <li>ENTERPRISE license (maxUsers>0): License seats only (NO grandfathering added)
* </ul>
*
* @return Maximum number of users allowed
* <p>IMPORTANT: Paid licenses REPLACE the limit, they don't add to grandfathering.
*
* @return Maximum number of users allowed (Integer.MAX_VALUE for unlimited)
*/
public int calculateMaxAllowedUsers() {
validateSettingsIntegrity();
@@ -259,20 +300,52 @@ public class UserLicenseSettingsService {
grandfatheredLimit = DEFAULT_USER_LIMIT;
}
int totalLimit = grandfatheredLimit;
if (applicationProperties.getPremium().isEnabled()) {
totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers();
// No license: use grandfathered limit
if (!hasPaidLicense()) {
log.debug("No license: using grandfathered limit of {}", grandfatheredLimit);
return grandfatheredLimit;
}
log.debug(
"Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})",
totalLimit,
grandfatheredLimit,
settings.getLicenseMaxUsers(),
applicationProperties.getPremium().isEnabled());
int licenseMaxUsers = settings.getLicenseMaxUsers();
return totalLimit;
// SERVER license (maxUsers=0): unlimited users
if (licenseMaxUsers == 0) {
log.debug("SERVER license: unlimited users allowed");
return Integer.MAX_VALUE;
}
// ENTERPRISE license (maxUsers>0): license seats only (replaces grandfathering)
log.debug(
"ENTERPRISE license: {} seats (grandfathered {} not added)",
licenseMaxUsers,
grandfatheredLimit);
return licenseMaxUsers;
}
/**
* Checks if a user is eligible to use OAuth/SAML authentication.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has an ENTERPRISE license (SSO is enterprise-only)
* </ul>
*
* @param user The user to check
* @return true if the user can use OAuth/SAML
*/
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
// Grandfathered users always have OAuth access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for OAuth", user.getUsername());
return true;
}
// Users can use OAuth/SAML only if system has ENTERPRISE license
boolean hasEnterpriseLicense = hasEnterpriseLicense();
log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
return hasEnterpriseLicense;
}
/**
@@ -408,4 +481,28 @@ public class UserLicenseSettingsService {
throw new IllegalStateException("Invalid key for grandfathered user signature", e);
}
}
private boolean hasPaidLicense() {
LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable();
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
}
/**
* Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SSO
* (OAuth/SAML).
*
* @return true if ENTERPRISE license is active
*/
private boolean hasEnterpriseLicense() {
LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable();
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.ENTERPRISE;
}
}