From 66b80a80c0b5a4d9c0eed4a225428b1a62cba4c8 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:31:47 +0100 Subject: [PATCH] fix(saas): personal teams must be allowed to share a name (new signups get no team) (#7180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug Every SaaS signup after the very first one is created with `team_id = NULL`, no team membership and no `home_team_id`. A brand-new account: ``` user_id | username | team_id | authenticationtype | home_team_id | memberships 952 | hedewot627@candaba.com | null | web | null | null ``` Since #7070 derives Processor access from leading a team, these accounts are silently redirected out of the Processor and back to the editor. ## Cause `SaasTeamService.createPersonalTeam` names every personal team the literal `"My Team"`, and `stirling_pdf.teams.name` was unique — so the insert throws a duplicate-key error for the second account onwards. Team creation is best-effort (caught, logged at WARN), so the account is created anyway, permanently team-less. Migration `20251211000000` had already dropped that constraint for exactly this reason, but it dropped it **by name** while the entity still declared `@Column(unique = true)`. With Flyway retired for `:saas` (#7100), `ddl-auto=update` reconciles the schema — so Hibernate re-created the constraint on the next boot under a generated name the old `DROP` could never match. The data bug predates #7070; that PR only made it visible. ## Changes - **`Team.name` no longer unique.** `TeamController` already enforces uniqueness for admin-created teams (`existsByNameIgnoreCase` on create and rename, 409), so nothing user-facing changes. `findByName` is only used for the `Default`/`Internal` system teams. - **Existing team-less accounts recover on authentication.** Signup is the only other place a team is assigned and nothing back-fills `team_id`, so without this they stay locked out. Guests excluded by design; healthy accounts short-circuit on a null check (`team` is `EAGER`). - **Tests:** team recovered, existing team untouched, guest stays team-less. ## Deploy order Needs `20260806000000_teams_name_drop_unique` (SaaS repo, `v3`) to drop the constraint from the live schema — **deployed after this**, or Hibernate re-adds it on the next boot. ## Verification `:saas:compileJava`, `:proprietary:compileJava`, spotless on both, and the `:saas` team/auth-filter tests (`TeamRecovery`: 3 tests, 0 failures). --- .../software/proprietary/model/Team.java | 4 +- .../SupabaseAuthenticationFilter.java | 24 +++++- .../SupabaseAuthenticationFilterMoreTest.java | 74 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java index 0009ee386e..a54959b0ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java @@ -29,7 +29,9 @@ public class Team implements Serializable { @Column(name = "team_id") private Long id; - @Column(name = "name", unique = true, nullable = false) + // Not unique: SaaS personal teams all share the name "My Team". TeamController enforces + // uniqueness for admin-created teams. + @Column(name = "name", nullable = false) private String name; @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index 890bc081a2..684e22a98f 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -239,7 +239,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { && !supabaseUser.isAnonymous()) { user = upgradeAnonymousUser(user, supabaseUser, jwt); } - return user; + return recoverMissingTeam(user); } return createUser(jwt, supabaseId, email, appMetadata); @@ -406,6 +406,28 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { return savedUser; } + /** + * Recover an account stranded without a team: signup is the only other place one is assigned, + * so a null team_id is otherwise permanent — and portal access derives from leading a team. + * Guests get none by design. + */ + private User recoverMissingTeam(User user) { + if (user.getTeam() != null + || ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())) { + return user; + } + try { + user.setTeam(saasTeamService.ensurePersonalTeam(user)); + log.info("Assigned a personal team to user {} which had none", user.getId()); + } catch (Exception e) { + log.warn( + "Could not assign a personal team to user {}: {}", + user.getId(), + e.getMessage()); + } + return user; + } + private boolean apiKeyAuthenticated(HttpServletRequest request) throws AuthenticationException { Authentication existing = SecurityContextHolder.getContext().getAuthentication(); if (existing != null && existing.isAuthenticated()) { diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java index 87e100375f..3b8a0292c7 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java @@ -585,4 +585,78 @@ class SupabaseAuthenticationFilterMoreTest { verify(userService, times(1)).saveUser(any(User.class)); } } + + @Nested + @DisplayName("Team recovery for existing accounts") + class TeamRecovery { + + private User existingWebUser(UUID supabaseId) { + User local = newUser("real@example.com"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.WEB); + return local; + } + + @Test + @DisplayName("an existing account with no team is given a personal team") + void assignsTeamWhenMissing() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")) + .thenReturn(fullJwt(supabaseId, "real@example.com", false, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); + + User local = existingWebUser(supabaseId); + Team recovered = new Team(); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + when(saasTeamService.ensurePersonalTeam(local)).thenReturn(recovered); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(saasTeamService).ensurePersonalTeam(local); + assertThat(local.getTeam()).isSameAs(recovered); + } + + @Test + @DisplayName("an account that already has a team is left alone") + void noOpWhenTeamPresent() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")) + .thenReturn(fullJwt(supabaseId, "real@example.com", false, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); + + User local = existingWebUser(supabaseId); + Team existing = new Team(); + local.setTeam(existing); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + assertThat(local.getTeam()).isSameAs(existing); + } + + @Test + @DisplayName("a guest session is never given a team") + void guestStaysTeamless() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")).thenReturn(fullJwt(supabaseId, null, true, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, null, true)); + + User local = newUser("anon_guest"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.ANONYMOUS); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + assertThat(local.getTeam()).isNull(); + } + } }