SaaS Consolidation (#6384)

Co-authored-by: ConnorYoh <[email protected]>
This commit is contained in:
Anthony Stirling
2026-05-21 16:05:35 +01:00
committed by GitHub
co-authored by ConnorYoh
parent 089de247b4
commit 9d081d1792
208 changed files with 14064 additions and 242 deletions
@@ -0,0 +1,39 @@
package stirling.software.proprietary.audit;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@Controller
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardWebController {
private final AuditConfigurationProperties auditConfig;
/** Display the audit dashboard. */
@GetMapping("/audit")
@Hidden
public String showDashboard(Model model) {
model.addAttribute("auditEnabled", auditConfig.isEnabled());
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
model.addAttribute("auditLevelInt", auditConfig.getLevel());
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
// Add audit level enum values for display
model.addAttribute("auditLevels", AuditLevel.values());
// Add audit event types for the dropdown
model.addAttribute("auditEventTypes", AuditEventType.values());
return "audit/dashboard";
}
}
@@ -50,13 +50,7 @@ public class AsyncConfig {
return adapter;
}
/**
* AI orchestration runs on a background executor, so the incoming request's {@code
* SecurityContext} must be propagated for downstream calls to see the authenticated user.
* Without this, {@code JobOwnershipService} scopes job keys without a user prefix and
* authenticated downloads fail with 403; {@code InternalApiClient} also falls back to the
* internal-API-user key instead of the caller's.
*/
/** Propagates the request's SecurityContext onto background AI-orchestration threads. */
@Bean(name = "aiStreamExecutor")
public Executor aiStreamExecutor() {
TaskExecutorAdapter adapter =
@@ -27,7 +27,7 @@ import stirling.software.common.service.TaskManager;
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin Job Management", description = "Admin-only Job Management APIs")
public class AdminJobController {
@@ -41,7 +41,7 @@ public class AdminJobController {
*/
@GetMapping("/job/stats")
@Operation(summary = "Get job statistics")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<JobStats> getJobStats() {
JobStats stats = taskManager.getJobStats();
log.info(
@@ -58,7 +58,7 @@ public class AdminJobController {
*/
@GetMapping("/job/queue/stats")
@Operation(summary = "Get job queue statistics")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> getQueueStats() {
Map<String, Object> queueStats = jobQueue.getQueueStats();
log.info("Admin requested queue stats: {} queued jobs", queueStats.get("queuedJobs"));
@@ -72,7 +72,7 @@ public class AdminJobController {
*/
@PostMapping("/job/cleanup")
@Operation(summary = "Cleanup old jobs")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> cleanupOldJobs() {
int beforeCount = taskManager.getJobStats().getTotalJobs();
taskManager.cleanupOldJobs();
@@ -52,7 +52,7 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@RestController
@RequestMapping("/api/v1/audit")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
@Tag(name = "Audit", description = "Only Enterprise - Audit related operations")
@@ -46,7 +46,7 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TeamService;
@@ -66,7 +66,7 @@ public class ProprietaryUIDataController {
private final UserRepository userRepository;
private final TeamRepository teamRepository;
private final SessionRepository sessionRepository;
private final DatabaseService databaseService;
private final DatabaseServiceInterface databaseService;
private final boolean runningEE;
private final ObjectMapper objectMapper;
private final UserLicenseSettingsService licenseSettingsService;
@@ -81,7 +81,7 @@ public class ProprietaryUIDataController {
UserRepository userRepository,
TeamRepository teamRepository,
SessionRepository sessionRepository,
DatabaseService databaseService,
DatabaseServiceInterface databaseService,
ObjectMapper objectMapper,
@Qualifier("runningEE") boolean runningEE,
UserLicenseSettingsService licenseSettingsService,
@@ -251,7 +251,7 @@ public class ProprietaryUIDataController {
}
@GetMapping("/admin-settings")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get admin settings data")
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeam();
@@ -450,7 +450,7 @@ public class ProprietaryUIDataController {
}
@GetMapping("/teams")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get teams list data")
public ResponseEntity<TeamsData> getTeamsData() {
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
@@ -476,7 +476,7 @@ public class ProprietaryUIDataController {
}
@GetMapping("/teams/{id}")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get team details data")
public ResponseEntity<TeamDetailsData> getTeamDetailsData(@PathVariable("id") Long id) {
Team team =
@@ -520,7 +520,7 @@ public class ProprietaryUIDataController {
}
@GetMapping("/database")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get database management data")
public ResponseEntity<DatabaseData> getDatabaseData() {
List<FileInfo> backupList = databaseService.getBackupList();
@@ -1,5 +1,6 @@
package stirling.software.proprietary.security;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -8,6 +9,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
@Component
@Profile("!saas")
@RequiredArgsConstructor
public class RateLimitResetScheduler {
@@ -12,6 +12,7 @@ import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import lombok.Getter;
@@ -71,6 +72,7 @@ public class DatabaseConfig {
@Bean
@Qualifier("dataSource")
@Primary
@Profile("!saas")
public DataSource dataSource() throws UnsupportedProviderException {
DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
@@ -0,0 +1,16 @@
package stirling.software.proprietary.security.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/** Standalone {@link PasswordEncoder} bean. */
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -9,6 +9,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
@@ -20,7 +21,6 @@ import org.springframework.security.config.annotation.web.configurers.CsrfConfig
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
@@ -69,6 +69,7 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@EnableWebSecurity
@EnableMethodSecurity
@DependsOn("runningProOrHigher")
@Profile("!saas")
public class SecurityConfiguration {
private final CustomUserDetailsService userDetailsService;
@@ -91,6 +92,7 @@ public class SecurityConfiguration {
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ClientRegistrationRepository clientRegistrationRepository;
private final PasswordEncoder passwordEncoder;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@@ -112,8 +114,8 @@ public class SecurityConfiguration {
@Autowired(required = false)
OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver,
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService) {
stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService,
PasswordEncoder passwordEncoder) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@@ -132,11 +134,7 @@ public class SecurityConfiguration {
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
this.clientRegistrationRepository = clientRegistrationRepository;
this.licenseSettingsService = licenseSettingsService;
}
@Bean
public static PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
this.passwordEncoder = passwordEncoder;
}
/**
@@ -293,7 +291,7 @@ public class SecurityConfiguration {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// TODO: IPRateLimitingFilter disabled limit is 1M (no-op) and raw Filter
// TODO: IPRateLimitingFilter disabled (limit is 1M, no-op) and raw Filter
// impl causes Spring Security async dispatch bug (response already committed
// errors on StreamingResponseBody endpoints). Re-enable once converted to
// OncePerRequestFilter with proper config-driven limits.
@@ -462,7 +460,7 @@ public class SecurityConfiguration {
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@@ -4,7 +4,6 @@ import static stirling.software.proprietary.security.configuration.ee.KeygenLice
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
@@ -28,28 +27,26 @@ public class EEAppConfig {
migrateEnterpriseSettingsToPremium(this.applicationProperties);
}
@Profile("security")
@Profile("security & !saas")
@Bean(name = "runningProOrHigher")
@Primary
public boolean runningProOrHigher() {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
}
@Profile("security")
@Profile("security & !saas")
@Bean(name = "license")
@Primary
public String licenseType() {
return licenseKeyChecker.getPremiumLicenseEnabledResult().name();
}
@Profile("security")
@Profile("security & !saas")
@Bean(name = "runningEE")
@Primary
public boolean runningEnterprise() {
return licenseKeyChecker.getPremiumLicenseEnabledResult() == License.ENTERPRISE;
}
@Profile("security & !saas")
@Bean(name = "SSOAutoLogin")
public boolean ssoAutoLogin() {
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
@@ -40,7 +40,7 @@ import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker
@RestController
@Slf4j
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
public class AdminLicenseController {
@@ -51,7 +51,7 @@ import tools.jackson.databind.ObjectMapper;
@AdminApi
@RequiredArgsConstructor
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Slf4j
public class AdminSettingsController {
@@ -585,7 +585,7 @@ public class AuthController {
* @param username Username of the user to disable MFA for
* @return Response indicating success or failure
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/mfa/disable/admin/{username}")
public ResponseEntity<?> disableMfaByAdmin(@PathVariable String username) {
try {
@@ -29,7 +29,7 @@ import stirling.software.proprietary.security.service.DatabaseService;
@Slf4j
@DatabaseApi
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Conditional(H2SQLCondition.class)
@RequiredArgsConstructor
public class DatabaseController {
@@ -52,7 +52,7 @@ public class InviteLinkController {
* @param request The HTTP request
* @return ResponseEntity with the invite link or error
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/generate")
public ResponseEntity<?> generateInviteLink(
@RequestParam(name = "email", required = false) String email,
@@ -257,7 +257,7 @@ public class InviteLinkController {
*
* @return List of active invite tokens
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/list")
public ResponseEntity<?> listInviteLinks() {
try {
@@ -297,7 +297,7 @@ public class InviteLinkController {
* @param inviteId The invite token ID to revoke
* @return Success or error response
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/revoke/{inviteId}")
public ResponseEntity<?> revokeInviteLink(@PathVariable Long inviteId) {
try {
@@ -324,7 +324,7 @@ public class InviteLinkController {
*
* @return Number of deleted tokens
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/cleanup")
public ResponseEntity<?> cleanupExpiredInvites() {
try {
@@ -30,7 +30,7 @@ public class TeamController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/create")
public ResponseEntity<?> createTeam(@RequestParam("name") String name) {
if (teamRepository.existsByNameIgnoreCase(name)) {
@@ -43,7 +43,7 @@ public class TeamController {
return ResponseEntity.ok(Map.of("message", "Team created successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/rename")
public ResponseEntity<?> renameTeam(
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
@@ -69,7 +69,7 @@ public class TeamController {
return ResponseEntity.ok(Map.of("message", "Team renamed successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/delete")
@Transactional
public ResponseEntity<?> deleteTeam(@RequestParam("teamId") Long teamId) {
@@ -100,7 +100,7 @@ public class TeamController {
return ResponseEntity.ok(Map.of("message", "Team deleted successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/addUser")
@Transactional
public ResponseEntity<?> addUserToTeam(
@@ -47,7 +47,7 @@ public class UIDataTessdataController {
private static final long REMOTE_TESSDATA_TTL_MS = 10 * 60 * 1000; // 10 minutes
@GetMapping("/tessdata-languages")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "List installed and remotely available tessdata languages")
public ResponseEntity<TessdataLanguagesResponse> getTessdataLanguages() {
TessdataLanguagesResponse response = new TessdataLanguagesResponse();
@@ -58,7 +58,7 @@ public class UIDataTessdataController {
}
@PostMapping("/tessdata/download")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Download selected tessdata languages from the official repository")
public ResponseEntity<Map<String, Object>> downloadTessdataLanguages(
@RequestBody TessdataDownloadRequest request) {
@@ -360,7 +360,7 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "Settings updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/admin/saveUser")
public ResponseEntity<?> saveUser(
@RequestParam(name = "username", required = true) String username,
@@ -468,7 +468,7 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User created successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/admin/inviteUsers")
public ResponseEntity<?> inviteUsers(
@RequestParam(name = "emails", required = true) String emails,
@@ -585,7 +585,7 @@ public class UserController {
}
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/admin/changeRole")
@Transactional
public ResponseEntity<?> changeRole(
@@ -651,7 +651,7 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/admin/changePasswordForUser")
public ResponseEntity<?> changePasswordForUser(
@RequestParam(name = "username") String username,
@@ -725,7 +725,7 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User password updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/admin/changeUserEnabled/{username}")
public ResponseEntity<?> changeUserEnabled(
@PathVariable("username") String username,
@@ -777,7 +777,7 @@ public class UserController {
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/admin/unlockUser/{username}")
@Audited(type = AuditEventType.SETTINGS_CHANGED, level = AuditLevel.BASIC)
public ResponseEntity<?> unlockUser(@PathVariable("username") String username) {
@@ -785,7 +785,7 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User account unlocked successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/admin/deleteUser/{username}")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public ResponseEntity<?> deleteUser(
@@ -24,7 +24,7 @@ import stirling.software.proprietary.security.service.DatabaseService;
@Slf4j
@Controller
@RequestMapping("/api/v1/database")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@EnterpriseEndpoint
@Conditional(H2SQLCondition.class)
@Tag(name = "Database", description = "Database APIs for backup, import, and management")
@@ -1,23 +1,34 @@
package stirling.software.proprietary.security.database;
import java.util.Arrays;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;
/** Returns {@code true} when the active deployment is genuinely on H2. */
public class H2SQLCondition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
var env = context.getEnvironment();
boolean enableCustomDatabase =
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
// If custom database is not enabled, H2 is used by default
if (!enableCustomDatabase) {
return true;
if (Arrays.asList(env.getActiveProfiles()).contains("saas")) {
return false;
}
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
return "h2".equalsIgnoreCase(dataSourceType);
// Legacy custom-DB block, if explicitly enabled, is authoritative.
boolean enableCustomDatabase =
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
if (enableCustomDatabase) {
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
return "h2".equalsIgnoreCase(dataSourceType);
}
String springDsUrl = env.getProperty("spring.datasource.url", String.class, "");
if (springDsUrl == null || springDsUrl.isBlank()) {
return true;
}
return springDsUrl.startsWith("jdbc:h2:");
}
}
@@ -1,7 +1,10 @@
package stirling.software.proprietary.security.database.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
@@ -26,6 +29,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByApiKey(String apiKey);
Optional<User> findByEmail(String email);
Optional<User> findBySupabaseId(UUID supabaseId);
Optional<User> findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId);
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
@@ -92,4 +99,23 @@ public interface UserRepository extends JpaRepository<User, Long> {
nativeQuery = true)
void deleteSettingsByUserIdAndKeys(
@Param("userId") Long userId, @Param("keys") List<String> keys);
/** Anonymous users (no username) created before the cut-off, streamed for batch cleanup. */
@Query("SELECT u.id FROM User u WHERE u.username IS NULL AND u.createdAt < :cutoffDate")
Stream<Long> findByUsernameIsNullAndCreatedAtBefore(
@Param("cutoffDate") LocalDateTime cutoffDate);
/** Users with an API key but no row in {@code user_credits}. */
@Query(
value =
"SELECT u.* FROM users u "
+ "LEFT JOIN user_credits uc ON uc.user_id = u.user_id "
+ "WHERE u.api_key IS NOT NULL AND uc.user_id IS NULL",
nativeQuery = true)
List<User> findUsersWithApiKeyButNoCredits();
/** Single-shot UPDATE that reassigns a user to a different team. */
@Modifying
@Query("UPDATE User u SET u.team.id = :teamId WHERE u.id = :userId")
int updateUserTeamId(@Param("userId") Long userId, @Param("teamId") Long teamId);
}
@@ -8,6 +8,7 @@ import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
@@ -37,6 +38,7 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Slf4j
@Component
@Profile("!saas")
public class UserAuthenticationFilter extends OncePerRequestFilter {
private final ApplicationProperties.Security securityProp;
@@ -6,6 +6,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
@@ -28,6 +29,7 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.common.util.RegexPatternUtils;
@Component
@Profile("!saas")
public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
private final Map<String, Bucket> apiBuckets = new ConcurrentHashMap<>();
@@ -5,5 +5,7 @@ public enum AuthenticationType {
@Deprecated(since = "1.0.2")
SSO,
OAUTH2,
SAML2
SAML2,
/** Supabase anonymous session. Saas profile only, no email yet. */
ANONYMOUS
}
@@ -7,6 +7,7 @@ import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.hibernate.annotations.CreationTimestamp;
@@ -50,12 +51,13 @@ public class User implements UserDetails, Serializable {
@JsonIgnore
private String password;
@Column(name = "apiKey")
@Column(name = "apiKey", unique = true)
@JsonIgnore
private String apiKey;
// Boxed so SaaS rows from Supabase can leave it null; isEnabled() treats null as enabled.
@Column(name = "enabled")
private boolean enabled;
private Boolean enabled;
@Column(name = "isFirstLogin")
private Boolean isFirstLogin = false;
@@ -81,6 +83,13 @@ public class User implements UserDetails, Serializable {
@Column(name = "oauth_grandfathered")
private Boolean oauthGrandfathered = false;
@Column(name = "email", unique = true)
private String email;
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
@Column(name = "supabase_id", unique = true)
private UUID supabaseId;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
private Set<Authority> authorities = new HashSet<>();
@@ -90,6 +99,7 @@ public class User implements UserDetails, Serializable {
@ElementCollection
@MapKeyColumn(name = "setting_key")
@Lob
@Column(name = "setting_value", columnDefinition = "text")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
@@ -107,6 +117,11 @@ public class User implements UserDetails, Serializable {
return Role.getRoleNameByRoleId(getRolesAsString());
}
@Override
public boolean isEnabled() {
return enabled == null || enabled;
}
public boolean isFirstLogin() {
return isFirstLogin != null && isFirstLogin;
}
@@ -0,0 +1,135 @@
package stirling.software.proprietary.security.saml2;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Slf4j
public class JwtSaml2AuthenticationRequestRepository
implements Saml2AuthenticationRequestRepository<Saml2PostAuthenticationRequest> {
private final Map<String, String> tokenStore;
private final JwtServiceInterface jwtService;
private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token";
public JwtSaml2AuthenticationRequestRepository(
Map<String, String> tokenStore,
JwtServiceInterface jwtService,
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
this.tokenStore = tokenStore;
this.jwtService = jwtService;
this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository;
}
@Override
public void saveAuthenticationRequest(
Saml2PostAuthenticationRequest authRequest,
HttpServletRequest request,
HttpServletResponse response) {
if (!jwtService.isJwtEnabled()) {
log.debug("V2 is not enabled, skipping SAMLRequest token storage");
return;
}
if (authRequest == null) {
removeAuthenticationRequest(request, response);
return;
}
Map<String, Object> claims = serializeSamlRequest(authRequest);
String token = jwtService.generateToken("", claims);
String relayState = authRequest.getRelayState();
tokenStore.put(relayState, token);
request.setAttribute(SAML_REQUEST_TOKEN, relayState);
response.addHeader(SAML_REQUEST_TOKEN, relayState);
log.debug("Saved SAMLRequest token with RelayState: {}", relayState);
}
@Override
public Saml2PostAuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) {
String token = extractTokenFromStore(request);
if (token == null) {
log.debug("No SAMLResponse token found in RelayState");
return null;
}
Map<String, Object> claims = jwtService.extractClaims(token);
return deserializeSamlRequest(claims);
}
@Override
public Saml2PostAuthenticationRequest removeAuthenticationRequest(
HttpServletRequest request, HttpServletResponse response) {
Saml2PostAuthenticationRequest authRequest = loadAuthenticationRequest(request);
String relayStateId = request.getParameter("RelayState");
if (relayStateId != null) {
tokenStore.remove(relayStateId);
log.debug("Removed SAMLRequest token for RelayState ID: {}", relayStateId);
}
return authRequest;
}
private String extractTokenFromStore(HttpServletRequest request) {
String authnRequestId = request.getParameter("RelayState");
if (authnRequestId != null && !authnRequestId.isEmpty()) {
String token = tokenStore.get(authnRequestId);
if (token != null) {
tokenStore.remove(authnRequestId);
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
return token;
} else {
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
}
}
return null;
}
private Map<String, Object> serializeSamlRequest(Saml2PostAuthenticationRequest authRequest) {
Map<String, Object> claims = new HashMap<>();
claims.put("id", authRequest.getId());
claims.put("relyingPartyRegistrationId", authRequest.getRelyingPartyRegistrationId());
claims.put("authenticationRequestUri", authRequest.getAuthenticationRequestUri());
claims.put("samlRequest", authRequest.getSamlRequest());
claims.put("relayState", authRequest.getRelayState());
return claims;
}
private Saml2PostAuthenticationRequest deserializeSamlRequest(Map<String, Object> claims) {
String relyingPartyRegistrationId = (String) claims.get("relyingPartyRegistrationId");
RelyingPartyRegistration relyingPartyRegistration =
relyingPartyRegistrationRepository.findByRegistrationId(relyingPartyRegistrationId);
if (relyingPartyRegistration == null) {
return null;
}
return Saml2PostAuthenticationRequest.withRelyingPartyRegistration(relyingPartyRegistration)
.id((String) claims.get("id"))
.authenticationRequestUri((String) claims.get("authenticationRequestUri"))
.samlRequest((String) claims.get("samlRequest"))
.relayState((String) claims.get("relayState"))
.build();
}
}
@@ -28,6 +28,7 @@ import java.util.stream.Collectors;
import javax.sql.DataSource;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.datasource.init.CannotReadScriptException;
import org.springframework.jdbc.datasource.init.ScriptException;
import org.springframework.stereotype.Service;
@@ -42,6 +43,7 @@ import stirling.software.proprietary.security.model.exception.BackupNotFoundExce
@Slf4j
@Service
@Profile("!saas")
public class DatabaseService implements DatabaseServiceInterface {
public static final String BACKUP_PREFIX = "backup_";
@@ -391,6 +393,7 @@ public class DatabaseService implements DatabaseServiceInterface {
*
* @return <code>String</code> of the H2 version
*/
@Override
public String getH2Version() {
String version = "Unknown";
@@ -411,39 +414,18 @@ public class DatabaseService implements DatabaseServiceInterface {
return version;
}
/*
* Checks if the current datasource is H2.
*
* @return true if the datasource is H2, false otherwise
*/
/** Returns {@code true} when the active runtime DataSource is genuinely H2. */
private boolean isH2Database() {
boolean isTypeH2 =
datasourceProps.getType().equalsIgnoreCase(ApplicationProperties.Driver.H2.name());
boolean isDBUrlH2 =
datasourceProps.getCustomDatabaseUrl().contains("h2")
|| datasourceProps.getCustomDatabaseUrl().contains("H2");
boolean isCustomDatabase = datasourceProps.isEnableCustomDatabase();
if (isCustomDatabase) {
if (isTypeH2 && !isDBUrlH2) {
log.warn(
"Datasource type is H2, but the URL does not contain 'h2'. "
+ "Please check your configuration.");
throw new IllegalStateException(
"Datasource type is H2, but the URL does not contain 'h2'. Please check"
+ " your configuration.");
} else if (!isTypeH2 && isDBUrlH2) {
log.warn(
"Datasource URL contains 'h2', but the type is not H2. "
+ "Please check your configuration.");
throw new IllegalStateException(
"Datasource URL contains 'h2', but the type is not H2. Please check your"
+ " configuration.");
}
try (Connection conn = dataSource.getConnection()) {
String product = conn.getMetaData().getDatabaseProductName();
return product != null && product.toLowerCase().contains("h2");
} catch (SQLException e) {
log.warn(
"Could not read DatabaseMetaData to determine driver; assuming non-H2 to avoid"
+ " issuing H2-only SQL against another database: {}",
e.getMessage());
return false;
}
boolean isH2 = isTypeH2 && isDBUrlH2;
return !isCustomDatabase || isH2;
}
/**
@@ -20,4 +20,7 @@ public interface DatabaseServiceInterface {
List<Pair<FileInfo, Boolean>> deleteAllBackups();
List<Pair<FileInfo, Boolean>> deleteLastBackup();
/** Display string for the active database product/version. */
String getH2Version();
}
@@ -338,6 +338,22 @@ public class UserService implements UserServiceInterface {
return userRepository.findByUsername(username);
}
/** Resolves a user by Supabase auth UUID; empty for rows with no supabase_id. */
public Optional<User> findBySupabaseId(UUID supabaseId) {
return userRepository.findBySupabaseId(supabaseId);
}
@Transactional
public void trackApiKeyFirstUse(User user) {
// No-op default; saas mode overrides via SaasUserExtensionService#trackApiKeyFirstUse.
}
/** Low-level user persistence; bypasses {@link #saveUserCore}'s settings/audit lifecycle. */
@Transactional
public User saveUser(User user) {
return userRepository.save(user);
}
public Optional<User> findByUsernameIgnoreCase(String username) {
return userRepository.findByUsernameIgnoreCase(username);
}
@@ -0,0 +1,42 @@
package stirling.software.proprietary.security.supabase;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Stirling's customer-facing Supabase project endpoints ({@code auth.stirling.com} in production).
* Overridable via {@code stirling.supabase.url} / {@code stirling.supabase.publishable-key}.
*/
@Component
public class SupabaseEndpoints {
public static final String DEFAULT_URL = "https://auth.stirling.com";
/** Publishable (anon) key — safe to ship in client/server code. */
public static final String DEFAULT_PUBLISHABLE_KEY =
"sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb"; // gitleaks:allow
@Value("${stirling.supabase.url:" + DEFAULT_URL + "}")
private String url;
@Value("${stirling.supabase.publishable-key:" + DEFAULT_PUBLISHABLE_KEY + "}")
private String publishableKey;
public String getUrl() {
return url;
}
public String getPublishableKey() {
return publishableKey;
}
/** JWT issuer claim used to validate tokens minted by this project. */
public String getIssuer() {
return url + "/auth/v1";
}
/** JWKS URL for {@code NimbusJwtDecoder.withJwkSetUri(...)}. */
public String getJwksUrl() {
return getIssuer() + "/.well-known/jwks.json";
}
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.security.supabase;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Produces a {@link JwtDecoder} bean for the proprietary Supabase login path. Only registered when
* {@code security.supabase.user-login.enabled=true}.
*/
@Slf4j
@Configuration
@ConditionalOnProperty(
prefix = "security.supabase.user-login",
name = "enabled",
havingValue = "true")
@RequiredArgsConstructor
public class SupabaseJwtDecoderFactory {
private final SupabaseUserLoginProperties properties;
@Bean
public JwtDecoder supabaseUserLoginJwtDecoder() {
if (!properties.isJwtConfigured()) {
log.warn(
"security.supabase.user-login.enabled=true but issuer URL is not set;"
+ " producing a fail-closed JwtDecoder that rejects every token."
+ " Set security.supabase.user-login.issuer to enable real verification.");
return token -> {
throw new org.springframework.security.oauth2.jwt.JwtException(
"Supabase user-login issuer not configured");
};
}
String jwks = properties.getIssuer() + "/.well-known/jwks.json";
log.info("Configuring proprietary-mode Supabase JwtDecoder with JWKS: {}", jwks);
return NimbusJwtDecoder.withJwkSetUri(jwks).build();
}
}
@@ -0,0 +1,32 @@
package stirling.software.proprietary.security.supabase;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;
/** Config for the optional Supabase login on proprietary deployments. */
@Data
@Component
@ConfigurationProperties(prefix = "security.supabase.user-login")
public class SupabaseUserLoginProperties {
/** Master switch. */
private boolean enabled = false;
/** Supabase project issuer URL, e.g. {@code https://abcd1234.supabase.co/auth/v1}. */
private String issuer;
/** Optional expected JWT audience; empty disables aud validation. */
private String expectedAud;
/** Clock skew tolerance for JWT exp validation. */
private long clockSkewSeconds = 120L;
/** When true, a Supabase JWT for an unknown email auto-creates a local user. */
private boolean autoCreate = false;
public boolean isJwtConfigured() {
return enabled && issuer != null && !issuer.isBlank();
}
}
@@ -0,0 +1,49 @@
package stirling.software.proprietary.security.supabase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
class SupabaseJwtDecoderFactoryTest {
@Test
void factoryReturnsFailClosedDecoderWhenIssuerMissing() {
SupabaseUserLoginProperties props = new SupabaseUserLoginProperties();
props.setEnabled(true);
// issuer intentionally not set
SupabaseJwtDecoderFactory factory = new SupabaseJwtDecoderFactory(props);
JwtDecoder decoder = factory.supabaseUserLoginJwtDecoder();
assertThatThrownBy(() -> decoder.decode("any-token"))
.isInstanceOf(JwtException.class)
.hasMessageContaining("not configured");
}
@Test
void jwtConfiguredReflectsBothEnabledAndIssuer() {
SupabaseUserLoginProperties props = new SupabaseUserLoginProperties();
assertThat(props.isJwtConfigured()).isFalse();
props.setEnabled(true);
assertThat(props.isJwtConfigured()).isFalse();
props.setIssuer("https://example.supabase.co/auth/v1");
assertThat(props.isJwtConfigured()).isTrue();
props.setEnabled(false);
assertThat(props.isJwtConfigured()).isFalse();
}
@Test
void blankIssuerCountsAsUnconfigured() {
SupabaseUserLoginProperties props = new SupabaseUserLoginProperties();
props.setEnabled(true);
props.setIssuer(" ");
assertThat(props.isJwtConfigured()).isFalse();
}
}