mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
fix(saas): personal teams must be allowed to share a name (new signups get no team) (#7180)
## 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).
This commit is contained in:
@@ -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)
|
||||
|
||||
+23
-1
@@ -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()) {
|
||||
|
||||
+74
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user