feat(licensing): enforce the user cap and surface capacity

Groundwork for capping the Server plan at 100 users per server. The licensing
rule itself needs no change: calculateMaxAllowedUsers already returns
licenseMaxUsers when it is positive and unlimited when it is zero, which is
exactly the behaviour we want once licences start carrying a real number. An
un-upgraded instance therefore enforces a capped licence correctly.

What did need work is the paths around it.

Closes two ways past the limit. Invite links were checked when the link was
generated, not when it was redeemed, so a link minted while slots were free
could still create the user over the cap; redemption now re-checks and returns
409 with a message naming the limit. And saveUserCore gained a final guard, so
a seventh creation path cannot be added without one.

The guard needs an exemption. INTERNAL_API_USER is excluded from
getTotalUsersCount, so charging its creation against the limit would strand an
installation already at its cap - it could not create the account it needs to
function. SaveUserRequest carries an explicit bypassUserLimit flag, set only by
the three InitialSecuritySetup bootstrap paths.

UserService reaches UserLicenseSettingsService through an ObjectProvider because
that service already injects UserService to count users; this is the same cycle
break it uses for LicenseKeyChecker.

Also surfaces what the capacity UI will need: server_quantity and
user_block_size read from licence metadata at all three verification paths
through one helper so they cannot drift, persisted so they survive the weekly
refresh, and exposed on /license-info and the admin payload. Both are
presentation only; maxUsers stays the enforced limit. The admin payload also
gains pendingInvites, so the UI can show what is consuming capacity - disabled
accounts and unredeemed invites both hold a slot - and offer a way to reclaim
it rather than making payment the only exit.

No behaviour changes for any licence in the wild.
This commit is contained in:
Connor Yoh
2026-08-13 16:00:12 +01:00
parent 6eca6ba65c
commit 06f2e0f0b2
16 changed files with 292 additions and 10 deletions
@@ -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();
@@ -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;
@@ -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.
@@ -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());
@@ -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.
*
* <p>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 {
@@ -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)
@@ -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()
@@ -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.
*
* <p>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.
*
* <p>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;
}
}
@@ -28,6 +28,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
* <li>mfaEnabled: false
* <li>mfaSecret: null
* <li>mfaLastUsedStep: null
* <li>bypassUserLimit: false
* </ul>
*/
@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.
*
* <p>Never set this on a path that creates a real person's account.
*/
@Builder.Default private final boolean bypassUserLimit = false;
}
@@ -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<UserLicenseSettingsService> 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
@@ -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);
}
}
@@ -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() {
@@ -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) {
@@ -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
@@ -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);
}
}
}
@@ -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 {