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:
ConnorYoh
2026-07-29 09:31:47 +00:00
committed by GitHub
parent e05b7f12da
commit 66b80a80c0
3 changed files with 100 additions and 2 deletions
@@ -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)
@@ -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()) {
@@ -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();
}
}
}