feat(admin): let admins change user roles

V32 adds role_changed_at and the role_change log. Role changes lock
the admin rows, so there is always one admin left; the last admin
cannot delete their own account. Login and refresh read the role
under the user lock. Tokens issued before a role change are refused.
This commit is contained in:
lobo committed 2026-10-04 20:09:22 +02:00
1 parent 3410cbc598
commit 365b69feb4
44 files changed
+1238 -75

No files matched your search

@@ -93,8 +93,12 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ReturnsTheUser() {
var token = jwtService.generateToken("0815", 123456789L, Role.USER);
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
jwtService.parseLoginToken(token));
UserPrincipal principal = jwtService.parseLoginToken(token).orElseThrow();
assertEquals("0815", principal.id());
assertEquals(123456789L, principal.steamId());
assertEquals(Role.USER, principal.role());
assertFalse(principal.issuedAt().isAfter(java.time.Instant.now()));
}
@Test
@@ -111,6 +115,16 @@ class JwtServiceTest extends AbstractUnitTest {
assertTrue(secondsLeft > 14 * 60 && secondsLeft <= 15 * 60, "expires in " + secondsLeft + "s");
}
@Test
void generateToken_IsNeverIssuedBeforeNotBefore() {
var notBefore = java.time.Instant.now().plusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var token = jwtService.generateToken("0815", null, Role.USER, notBefore);
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload();
assertEquals(notBefore, claims.getIssuedAt().toInstant());
}
@Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() {
var ticket = jwtService.generateProfileTicket(42L, Role.USER, List.of(), List.of());
@@ -222,4 +222,31 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
.content("{\"email\":\"" + EMAIL + "\"}"))
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
}
@Test
void theOnlyAdmin_CannotDeleteTheOwnAccount() throws Exception {
jdbc.update("UPDATE users SET role = 'USER' WHERE role = 'PLATFORM_ADMIN'");
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", EMAIL);
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
deleteAccount(access, PASSWORD).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("LAST_ADMIN"));
assertThat(jdbc.queryForObject("SELECT deleted_at IS NULL FROM users WHERE email = ?", Boolean.class, EMAIL)).isTrue();
}
@Test
void oneOfTwoAdmins_CanDeleteTheOwnAccount() throws Exception {
String otherAdmin = "delete-flow-admin@example.com";
jdbc.update("UPDATE users SET role = 'USER' WHERE role = 'PLATFORM_ADMIN'");
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", EMAIL);
jdbc.update("DELETE FROM users WHERE email = ?", otherAdmin);
jdbc.update("INSERT INTO users (email, password, enabled, role) VALUES (?, 'x', true, 'PLATFORM_ADMIN')", otherAdmin);
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
try {
deleteAccount(access, PASSWORD).andExpect(status().isNoContent());
} finally {
jdbc.update("DELETE FROM users WHERE email = ?", otherAdmin);
}
}
}
@@ -56,12 +56,28 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() {
jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice);
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true));
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true),
new CoreAccountState(bob, null, null, false));
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null));
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null),
new CoreAccountState(bob, null, null, false, null));
assertThat(repository.findState(-1L)).isEmpty();
}
@Test
void findStates_CarryTheRoleChangeTime() {
jdbc.update("update users set role_changed_at = ? where id = ?", Timestamp.from(NOW), alice);
assertThat(repository.findState(alice)).get().extracting(CoreAccountState::roleChangedAt).isEqualTo(NOW);
assertThat(repository.findAllStates()).extracting(CoreAccountState::roleChangedAt).containsExactly(NOW, null);
}
@Test
void lockActiveAdmins_ListsTheActiveAdmins() {
jdbc.update("update users set role = 'PLATFORM_ADMIN' where id = ?", bob);
jdbc.update("update users set role = 'PLATFORM_ADMIN', enabled = false where id = ?", alice);
assertThat(repository.lockActiveAdmins()).containsExactly(bob);
}
@Test
void flagDeleted_SetsTheFlagAndRevokesOnlyThatUsersTokens() {
assertThat(repository.flagDeleted(alice, NOW)).isEqualTo(1);
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.when;
class AccountCleanupServiceTest extends AbstractUnitTest {
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true));
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null));
private static final Duration RETENTION = Duration.ofDays(30);
@@ -1,6 +1,7 @@
package auth_service.modules.account.service;
import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.mail.AccountMailer;
@@ -83,6 +84,25 @@ class AccountServiceTest extends AbstractUnitTest {
assertThat(deletedFrom.getValue()).isBetween(earliest, latest);
}
@Test
void deleteAccount_TheLastAdminIsRefusedAndNothingIsFlagged() {
when(accountRepository.lockActiveAdmins()).thenReturn(List.of(USER_ID));
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "right")).isInstanceOf(LastAdminException.class);
verify(accountRepository, never()).flagDeleted(anyLong(), any());
verifyNoInteractions(accountMailer, coreAccountService);
}
@Test
void deleteAccount_OneOfTwoAdminsIsFlagged() {
when(accountRepository.lockActiveAdmins()).thenReturn(List.of(USER_ID, 43L));
service.deleteAccount(USER_ID, "right");
verify(accountRepository).flagDeleted(eq(USER_ID), any());
}
@Test
void deleteAccount_WrongPasswordFlagsNothing() {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
@@ -14,6 +14,7 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import tools.jackson.databind.JsonNode;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@@ -71,8 +72,9 @@ class CoreAccountServiceTest extends AbstractUnitTest {
expect("applyStates");
expect("replaceStates");
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true)));
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false)));
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true,
Instant.parse("2026-10-04T12:00:00Z"))));
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null)));
core.verify();
}
@@ -22,7 +22,7 @@ import static org.mockito.Mockito.when;
class CoreAccountStatePublisherTest extends AbstractUnitTest {
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true);
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null);
@Mock
private AccountRepository accountRepository;
@@ -0,0 +1,144 @@
package auth_service.modules.admin;
import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.service.AccountService;
import auth_service.modules.admin.exception.AdminActionRefusedException;
import auth_service.modules.admin.service.RoleChangeService;
import auth_service.shared.account.ActiveAdmins;
import auth_service.shared.security.Role;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
import java.util.Arrays;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Two admins are the only admins. The first transaction locks both and demotes one of them, not committed. The second
* write must wait on the locked admin rows and then see the new state. The order is fixed, not timed.
*/
class AdminLockRaceTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
@Autowired
private JdbcTemplate jdbc;
@Autowired
private DataSource dataSource;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private RoleChangeService roleChangeService;
@Autowired
private AccountService accountService;
private long adminA;
private long adminB;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private long insertAdmin(String email) {
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) "
+ "VALUES (?, ?, true, 'PLATFORM_ADMIN', ?) RETURNING id", Long.class, email,
passwordEncoder.encode(PASSWORD), email.substring(0, 12));
addSettingsRows();
return id;
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM users");
adminA = insertAdmin("lock-race-a@example.com");
adminB = insertAdmin("lock-race-b@example.com");
}
@AfterEach
void cleanUp() {
executor.shutdownNow();
jdbc.update("DELETE FROM users WHERE email LIKE 'lock-race-%'");
}
private <T> Future<T> asAdmin(long userId, Callable<T> call) {
return executor.submit(() -> {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal(String.valueOf(userId), null, Role.PLATFORM_ADMIN), null,
Arrays.stream(Role.values()).map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())).toList()));
try {
return call.call();
} finally {
SecurityContextHolder.clearContext();
}
});
}
private void waitForLock() throws InterruptedException {
long deadline = System.currentTimeMillis() + 10_000;
while (jdbc.queryForObject("SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock'",
Integer.class) == 0) {
assertThat(System.currentTimeMillis()).as("the second write waits for the first").isLessThan(deadline);
Thread.sleep(20);
}
}
private <T> Future<T> secondWrite(long actor, Callable<T> call) throws Exception {
try (Connection first = dataSource.getConnection(); Statement statement = first.createStatement()) {
first.setAutoCommit(false);
statement.executeQuery(ActiveAdmins.LOCK).close();
statement.executeUpdate("UPDATE users SET role = 'USER' WHERE id = " + adminB);
Future<T> second = asAdmin(actor, call);
waitForLock();
Thread.sleep(300);
assertThat(second.isDone()).as("the second write still waits").isFalse();
first.commit();
return second;
}
}
@Test
void adminDemotedByAnotherTransaction_CannotDemoteTheOtherAdmin() throws Exception {
Future<Void> second = secondWrite(adminB, () -> {
roleChangeService.changeRole(adminB, adminA, Role.PLATFORM_ADMIN, Role.USER);
return null;
});
assertThatThrownBy(() -> second.get(10, TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class)
.cause().isInstanceOfSatisfying(AdminActionRefusedException.class,
e -> assertThat(e.getCode()).isEqualTo("ACTOR_NOT_ADMIN"));
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, adminA))
.isEqualTo("PLATFORM_ADMIN");
}
@Test
void adminWhoBecomesTheLastAdminWhileWaiting_CannotDeleteTheAccount() throws Exception {
Future<Void> second = secondWrite(adminA, () -> {
accountService.deleteAccount(adminA, PASSWORD);
return null;
});
assertThatThrownBy(() -> second.get(10, TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class)
.cause().isInstanceOf(LastAdminException.class);
assertThat(jdbc.queryForObject("SELECT deleted_at IS NULL FROM users WHERE id = ?", Boolean.class, adminA)).isTrue();
}
}
@@ -0,0 +1,132 @@
package auth_service.modules.admin;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.modules.admin.service.RoleChangeService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.Role;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* A login reads the role before its bcrypt check. A demotion that commits during that check must not leave the login
* with an access token that carries the old role and still passes the role change check.
*/
class RoleChangeLoginRaceTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String ADMIN = "login-race-admin@example.com";
private static final String VICTIM = "login-race-victim@example.com";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@MockitoSpyBean
private PasswordEncoder passwordEncoder;
@Autowired
private RoleChangeService roleChangeService;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private long adminId;
private long victimId;
private long insertAdmin(String email) {
jdbc.update("DELETE FROM users WHERE email = ?", email);
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) "
+ "VALUES (?, ?, true, 'PLATFORM_ADMIN', ?) RETURNING id", Long.class, email,
passwordEncoder.encode(PASSWORD), email.substring(0, 16));
addSettingsRows();
return id;
}
@BeforeEach
void seed() {
adminId = insertAdmin(ADMIN);
victimId = insertAdmin(VICTIM);
}
@AfterEach
void cleanUp() {
executor.shutdownNow();
jdbc.update("DELETE FROM role_change WHERE target_user_id = ?", victimId);
jdbc.update("DELETE FROM users WHERE email LIKE 'login-race-%'");
}
private void demoteVictimInAnotherThread() throws Exception {
executor.submit(() -> {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal(String.valueOf(adminId), null, Role.PLATFORM_ADMIN), null,
Arrays.stream(Role.values()).map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())).toList()));
try {
roleChangeService.changeRole(adminId, victimId, Role.PLATFORM_ADMIN, Role.USER);
} finally {
SecurityContextHolder.clearContext();
}
return null;
}).get(10, TimeUnit.SECONDS);
}
@Test
void loginRacingADemotionGetsNoTokenWithTheOldRole() throws Exception {
AtomicBoolean armed = new AtomicBoolean(true);
doAnswer(invocation -> {
boolean matches = (boolean) invocation.callRealMethod();
if (armed.getAndSet(false)) {
demoteVictimInAnotherThread();
Instant validFrom = jdbc.queryForObject("SELECT role_changed_at FROM users WHERE id = ?",
Timestamp.class, victimId).toInstant();
// The bcrypt check of a real login can end after the next second starts.
while (Instant.now().isBefore(validFrom.plusMillis(5))) {
Thread.sleep(5);
}
}
return matches;
}).when(passwordEncoder).matches(any(), any());
MockHttpServletResponse login = mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + VICTIM + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse();
assertThat(armed.get()).as("the demotion ran inside the login").isFalse();
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, victimId)).isEqualTo("USER");
int adminList = login.getStatus() != 200 ? 401 : mockMvc.perform(get("/api/admin/users")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE,
login.getCookie(AuthCookies.ACCESS_COOKIE).getValue())))
.andReturn().getResponse().getStatus();
assertThat(adminList).as("admin list with the token of a login that raced the demotion").isIn(401, 403);
}
}
@@ -337,4 +337,146 @@ class AdminFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.code").value("TARGET_IS_ADMIN"));
block(admin, 999_999_999L, true).andExpect(status().isNotFound());
}
private ResultActions changeRole(Session session, long userId, String role, String expectedRole) throws Exception {
return mockMvc.perform(put("/api/admin/users/" + userId + "/role").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"role\":\"" + role + "\",\"expectedRole\":\"" + expectedRole + "\"}"));
}
private ResultActions roleChanges(Session session, long userId) throws Exception {
return mockMvc.perform(get("/api/admin/users/" + userId + "/role-changes")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())));
}
private String activeRole(Session session) throws Exception {
return com.jayway.jsonpath.JsonPath.read(mockMvc.perform(get("/api/users/me")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))).andReturn().getResponse()
.getContentAsString(), "$.activeRole");
}
@Test
void roleChange_GrantEndsTheOldTokenAndTheNewOneCarriesTheRole() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
assertThat(me(player)).isEqualTo(200);
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isNoContent());
assertThat(me(player)).isEqualTo(401);
assertThat(activeRole(refresh(player))).isEqualTo("AUDITOR");
roleChanges(admin, playerId).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].actorEmail").value(ADMIN))
.andExpect(jsonPath("$.results[0].oldRole").value("USER"))
.andExpect(jsonPath("$.results[0].newRole").value("AUDITOR"));
assertThat(audits("ROLE_CHANGE", playerId)).isEqualTo(1);
}
@Test
void roleChange_SameRoleWritesNothing() throws Exception {
changeRole(Session.of(login(ADMIN)), playerId, "USER", "USER").andExpect(status().isNoContent());
assertThat(audits("ROLE_CHANGE", playerId)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM role_change WHERE target_user_id = ?", Long.class, playerId)).isZero();
}
@Test
void roleChange_DemotedAdminLosesTheRightsAtOnce() throws Exception {
Session admin = Session.of(login(ADMIN));
Session other = Session.of(login(OTHER_ADMIN));
listUsers(other).andExpect(status().isOk());
changeRole(admin, otherAdminId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
listUsers(other).andExpect(status().isUnauthorized());
listUsers(refresh(other)).andExpect(status().isForbidden());
}
@Test
void roleChange_AdminWhoViewsAsUserIsAuditorAfterTheDemotionToAuditor() throws Exception {
Session admin = Session.of(login(ADMIN));
Session asUser = Session.of(viewAs(Session.of(login(OTHER_ADMIN)), "USER"));
changeRole(admin, otherAdminId, "AUDITOR", "PLATFORM_ADMIN").andExpect(status().isNoContent());
assertThat(activeRole(refresh(asUser))).isEqualTo("AUDITOR");
}
@Test
void roleChange_RefusesOwnRoleStaleRoleBlockedTargetBadRoleAndUnknownUser() throws Exception {
Session admin = Session.of(login(ADMIN));
changeRole(admin, jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, ADMIN), "USER",
"PLATFORM_ADMIN").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("SELF_ACTION"));
changeRole(admin, playerId, "AUDITOR", "AUDITOR").andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ROLE_CHANGED"));
changeRole(admin, playerId, "GOD", "USER").andExpect(status().isBadRequest());
changeRole(admin, 999_999_999L, "AUDITOR", "USER").andExpect(status().isNotFound());
block(admin, playerId, true).andExpect(status().isNoContent());
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TARGET_IS_BLOCKED"));
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, playerId)).isEqualTo("USER");
}
@Test
void roleEndpoints_AreForAdminsOnly() throws Exception {
Session player = Session.of(login(PLAYER));
jdbc.update("UPDATE users SET role = 'AUDITOR' WHERE email = ?", OTHER_ADMIN);
Session auditor = Session.of(login(OTHER_ADMIN));
Session viewingAsAuditor = Session.of(viewAs(Session.of(login(ADMIN)), "AUDITOR"));
for (Session session : new Session[]{player, auditor, viewingAsAuditor}) {
changeRole(session, playerId, "AUDITOR", "USER").andExpect(status().isForbidden());
roleChanges(session, playerId).andExpect(status().isForbidden());
}
mockMvc.perform(get("/api/admin/users/" + playerId + "/role-changes")).andExpect(status().isUnauthorized());
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, playerId)).isEqualTo("USER");
}
@Test
void roleChange_TokenFromTheSecondOfTheDemotionLosesTheAdminRights() throws Exception {
Session admin = Session.of(login(ADMIN));
while (System.currentTimeMillis() % 1000 > 50) {
Thread.onSpinWait();
}
Session other = Session.of(login(OTHER_ADMIN));
changeRole(admin, otherAdminId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
listUsers(other).andExpect(status().isUnauthorized());
}
@Test
void roleChange_RefreshInTheSecondOfTheChangeGivesAValidToken() throws Exception {
Session admin = Session.of(login(ADMIN));
while (System.currentTimeMillis() % 1000 > 50) {
Thread.onSpinWait();
}
Session player = Session.of(login(PLAYER));
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isNoContent());
Session refreshed = refresh(player);
assertThat(me(player)).isEqualTo(401);
assertThat(me(refreshed)).isEqualTo(200);
assertThat(activeRole(refreshed)).isEqualTo("AUDITOR");
}
@Test
void roleChange_TokenFromBetweenTwoChangesInOneSecondLosesTheRole() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
while (System.currentTimeMillis() % 1000 > 50) {
Thread.onSpinWait();
}
changeRole(admin, playerId, "PLATFORM_ADMIN", "USER").andExpect(status().isNoContent());
Session promoted = refresh(player);
changeRole(admin, playerId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
listUsers(promoted).andExpect(status().isUnauthorized());
}
}
@@ -0,0 +1,110 @@
package auth_service.modules.admin.repo;
import auth_service.modules.admin.model.RoleChange;
import auth_service.modules.admin.model.RoleTarget;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class RoleChangeRepositoryTest extends AbstractPersistenceTest {
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS);
@Autowired
private RoleChangeRepository repository;
@Autowired
private JdbcTemplate jdbc;
private long admin;
private long otherAdmin;
private long user;
private long insert(String email, String role) {
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, role) VALUES (?, 'x', true, ?) RETURNING id",
Long.class, email, role);
}
private void addLogin(long userId) {
jdbc.update("""
INSERT INTO refresh_tokens (user_id, token_hash, family_id, expires_at, created_at, active_role)
VALUES (?, ?, ?, now() + interval '30 days', now(), 'USER')""", userId, UUID.randomUUID().toString(),
UUID.randomUUID());
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM role_change");
jdbc.update("DELETE FROM users");
admin = insert("admin@example.com", "PLATFORM_ADMIN");
otherAdmin = insert("other-admin@example.com", "PLATFORM_ADMIN");
user = insert("user@example.com", "USER");
}
@Test
void lockActiveAdmins_ListsOnlyActiveAdminsByIdAndNoOtherRole() {
long blocked = insert("blocked-admin@example.com", "PLATFORM_ADMIN");
jdbc.update("UPDATE users SET enabled = false WHERE id = ?", blocked);
long deleted = insert("deleted-admin@example.com", "PLATFORM_ADMIN");
jdbc.update("UPDATE users SET deleted_at = now() WHERE id = ?", deleted);
long pending = insert("pending-admin@example.com", "PLATFORM_ADMIN");
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", pending);
assertThat(repository.lockActiveAdmins()).containsExactly(admin, otherAdmin);
}
@Test
void lockTarget_ReadsRoleBlockDeletedAndStatus() {
jdbc.update("UPDATE users SET enabled = false WHERE id = ?", user);
assertThat(repository.lockTarget(user)).contains(new RoleTarget(Role.USER, true, false, "ACTIVE", null));
jdbc.update("UPDATE users SET status = 'DENIED' WHERE id = ?", user);
assertThat(repository.lockTarget(user)).get().extracting(RoleTarget::deleted).isEqualTo(true);
assertThat(repository.lockTarget(-1L)).isEmpty();
}
@Test
void updateRole_SetsRoleAndTimeAndClearsTheActiveRoleOfOpenLoginsOnly() {
addLogin(user);
addLogin(user);
jdbc.update("UPDATE refresh_tokens SET revoked_at = now() WHERE id = (SELECT min(id) FROM refresh_tokens WHERE user_id = ?)", user);
addLogin(otherAdmin);
repository.updateRole(user, Role.AUDITOR, NOW);
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, user)).isEqualTo("AUDITOR");
assertThat(jdbc.queryForObject("SELECT role_changed_at FROM users WHERE id = ?", Timestamp.class, user).toInstant())
.isEqualTo(NOW);
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL",
String.class, user)).containsOnlyNulls().hasSize(1);
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NOT NULL",
String.class, user)).containsExactly("USER");
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ?", String.class, otherAdmin))
.containsExactly("USER");
}
@Test
void findChanges_NewestFirstLimitedWithTheActorEmailAndNullForAGoneActor() {
repository.insertChange(admin, user, Role.USER, Role.AUDITOR, NOW.minusSeconds(20));
repository.insertChange(otherAdmin, user, Role.AUDITOR, Role.PLATFORM_ADMIN, NOW.minusSeconds(10));
repository.insertChange(admin, user, Role.PLATFORM_ADMIN, Role.USER, NOW);
repository.insertChange(admin, otherAdmin, Role.USER, Role.AUDITOR, NOW);
jdbc.update("DELETE FROM users WHERE id = ?", otherAdmin);
assertThat(repository.findChanges(user, 10)).containsExactly(
new RoleChange(admin, "admin@example.com", Role.PLATFORM_ADMIN, Role.USER, NOW),
new RoleChange(otherAdmin, null, Role.AUDITOR, Role.PLATFORM_ADMIN, NOW.minusSeconds(10)),
new RoleChange(admin, "admin@example.com", Role.USER, Role.AUDITOR, NOW.minusSeconds(20)));
assertThat(repository.findChanges(user, 2)).hasSize(2);
}
}
@@ -0,0 +1,176 @@
package auth_service.modules.admin.service;
import auth_service.modules.admin.exception.AdminActionRefusedException;
import auth_service.modules.admin.exception.TargetUserNotFoundException;
import auth_service.modules.admin.model.RoleChange;
import auth_service.modules.admin.model.RoleTarget;
import auth_service.modules.admin.repo.RoleChangeRepository;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class RoleChangeServiceTest extends AbstractUnitTest {
private static final long ACTOR = 1L;
private static final long TARGET = 2L;
@Mock
private RoleChangeRepository repository;
@Mock
private AdminAuditLog adminAuditLog;
@Mock
private AccountStatePublisher accountStatePublisher;
private RoleChangeService service;
@BeforeEach
void setUp() {
service = new RoleChangeService(repository, adminAuditLog, accountStatePublisher);
lenient().when(repository.lockActiveAdmins()).thenReturn(List.of(ACTOR));
target(Role.USER, false, false, "ACTIVE");
}
private void target(Role role, boolean blocked, boolean deleted, String status) {
target(role, blocked, deleted, status, null);
}
private void target(Role role, boolean blocked, boolean deleted, String status, Instant roleChangedAt) {
lenient().when(repository.lockTarget(TARGET))
.thenReturn(Optional.of(new RoleTarget(role, blocked, deleted, status, roleChangedAt)));
}
private String refusal(Role expectedRole, Role newRole) {
try {
service.changeRole(ACTOR, TARGET, expectedRole, newRole);
} catch (AdminActionRefusedException e) {
return e.getCode();
}
throw new AssertionError("not refused");
}
private void verifyNothingWritten() {
verify(repository, never()).updateRole(anyLong(), any(), any());
verify(repository, never()).insertChange(anyLong(), anyLong(), any(), any(), any());
verifyNoInteractions(adminAuditLog, accountStatePublisher);
}
@Test
void grant_WritesTheRoleTheLogTheAuditAndTellsCore() {
service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR);
Instant before = Instant.now();
ArgumentCaptor<Instant> validFrom = ArgumentCaptor.forClass(Instant.class);
verify(repository).updateRole(eq(TARGET), eq(Role.AUDITOR), validFrom.capture());
verify(repository).insertChange(eq(ACTOR), eq(TARGET), eq(Role.USER), eq(Role.AUDITOR), any());
assertThat(validFrom.getValue()).isEqualTo(validFrom.getValue().truncatedTo(ChronoUnit.SECONDS))
.isAfter(before.minusSeconds(1)).isBeforeOrEqualTo(before.plusSeconds(2));
verify(adminAuditLog).record(ACTOR, "ROLE_CHANGE", TARGET, "USER>AUDITOR");
verify(accountStatePublisher).publish(TARGET);
}
@Test
void secondChangeInTheSameSecond_IsValidFromAfterTheFirst() {
Instant previous = Instant.now().truncatedTo(ChronoUnit.SECONDS).plusSeconds(1);
target(Role.AUDITOR, false, false, "ACTIVE", previous);
service.changeRole(ACTOR, TARGET, Role.AUDITOR, Role.USER);
verify(repository).updateRole(TARGET, Role.USER, previous.plusSeconds(1));
}
@Test
void changeRole_LocksTheAdminsBeforeTheTarget() {
service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR);
InOrder order = inOrder(repository);
order.verify(repository).lockActiveAdmins();
order.verify(repository).lockTarget(TARGET);
order.verify(repository).updateRole(eq(TARGET), eq(Role.AUDITOR), any());
}
@Test
void ownRole_IsRefusedBeforeAnyLock() {
assertThatThrownBy(() -> service.changeRole(ACTOR, ACTOR, Role.PLATFORM_ADMIN, Role.USER))
.isInstanceOfSatisfying(AdminActionRefusedException.class, e -> assertThat(e.getCode()).isEqualTo("SELF_ACTION"));
verifyNoInteractions(repository);
verifyNothingWritten();
}
@Test
void actorWhoIsNoAdminAnyMore_IsRefused() {
when(repository.lockActiveAdmins()).thenReturn(List.of(9L));
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("ACTOR_NOT_ADMIN");
verifyNothingWritten();
}
@Test
void staleExpectedRole_IsRefused() {
assertThat(refusal(Role.AUDITOR, Role.PLATFORM_ADMIN)).isEqualTo("ROLE_CHANGED");
verifyNothingWritten();
}
@Test
void deletedBlockedPendingAndUnconfirmedTargets_AreRefused() {
target(Role.USER, false, true, "ACTIVE");
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("ALREADY_DELETED");
target(Role.USER, true, false, "ACTIVE");
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_BLOCKED");
target(Role.USER, false, false, "PENDING");
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_PENDING");
target(Role.USER, false, false, "UNCONFIRMED");
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_UNCONFIRMED");
verifyNothingWritten();
}
@Test
void unknownTarget_IsNotFound() {
when(repository.lockTarget(TARGET)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR))
.isInstanceOf(TargetUserNotFoundException.class);
verifyNothingWritten();
}
@Test
void sameRole_WritesNothing() {
service.changeRole(ACTOR, TARGET, Role.USER, Role.USER);
verifyNothingWritten();
}
@Test
void findChanges_ReadsTheLog() {
List<RoleChange> log = List.of(new RoleChange(ACTOR, "a@example.com", Role.USER, Role.AUDITOR, Instant.now()));
when(repository.findChanges(TARGET, 50)).thenReturn(log);
assertThat(service.findChanges(TARGET)).isEqualTo(log);
}
}
@@ -1,7 +1,5 @@
package auth_service.modules.login.service;
import auth_service.shared.security.Role;
import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.AccountBlockedException;
import auth_service.modules.login.exception.AccountPendingException;
import auth_service.modules.login.exception.InvalidCredentialsException;
@@ -47,9 +45,6 @@ class LoginServiceTest extends AbstractUnitTest {
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private TokenGenerator tokenGenerator;
@Mock
private LoginAttemptService loginAttemptService;
@@ -65,8 +60,8 @@ class LoginServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash");
loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService,
refreshTokenService, cacheManager);
loginService = new LoginService(minimalUserRepository, passwordEncoder, loginAttemptService, refreshTokenService,
cacheManager);
user = User.builder()
.email("test@example.com")
.password("password")
@@ -84,8 +79,7 @@ class LoginServiceTest extends AbstractUnitTest {
.build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
when(tokenGenerator.generateToken("815", null, Role.USER)).thenReturn("mock-token");
when(refreshTokenService.startFamily(815L, dbUser.getPassword())).thenReturn("mock-refresh");
when(refreshTokenService.startSession(815L, dbUser.getPassword())).thenReturn(new Session("mock-token", "mock-refresh"));
// Act
var result = loginService.login(user, IP, null);
@@ -176,7 +170,7 @@ class LoginServiceTest extends AbstractUnitTest {
// Act & Assert
assertThrows(AccountBlockedException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any());
verify(refreshTokenService, never()).startSession(anyLong(), any());
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@@ -188,7 +182,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
assertThrows(AccountPendingException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any());
verify(refreshTokenService, never()).startSession(anyLong(), any());
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@@ -198,7 +192,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any());
verify(refreshTokenService, never()).startSession(anyLong(), any());
}
@Test
@@ -220,18 +214,6 @@ class LoginServiceTest extends AbstractUnitTest {
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
}
@Test
void login_TheTokenCarriesTheStoredRole() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
.role(Role.PLATFORM_ADMIN).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
loginService.login(user, IP, null);
verify(tokenGenerator).generateToken("815", null, Role.PLATFORM_ADMIN);
}
@Test
void login_TooManyAttempts() {
// Arrange
@@ -252,6 +234,6 @@ class LoginServiceTest extends AbstractUnitTest {
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(refreshTokenService, never()).startFamily(anyLong(), any());
verify(refreshTokenService, never()).startSession(anyLong(), any());
}
}
@@ -0,0 +1,42 @@
package auth_service.shared.security;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class UserTokenStateTest extends AbstractUnitTest {
private static final Instant CHANGE = Instant.parse("2026-10-04T12:00:00Z");
@Test
void activeWithoutRoleChange_AcceptsAnyToken() {
assertTrue(new UserTokenState(true, null).accepts(CHANGE));
assertTrue(new UserTokenState(true, null).accepts(null));
}
@Test
void inactive_RefusesEveryToken() {
assertFalse(UserTokenState.INACTIVE.accepts(CHANGE));
assertFalse(new UserTokenState(false, CHANGE).accepts(CHANGE.plusSeconds(60)));
}
@Test
void tokenFromBeforeTheRoleChange_IsRefused() {
assertFalse(new UserTokenState(true, CHANGE).accepts(CHANGE.minusSeconds(1)));
}
@Test
void tokenFromTheChangeSecondOrLater_IsAccepted() {
assertTrue(new UserTokenState(true, CHANGE).accepts(CHANGE));
assertTrue(new UserTokenState(true, CHANGE).accepts(CHANGE.plusSeconds(1)));
}
@Test
void tokenWithoutIssueTime_IsRefusedAfterARoleChange() {
assertFalse(new UserTokenState(true, CHANGE).accepts(null));
}
}
@@ -37,12 +37,12 @@
"applyStates": {
"method": "POST",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]}
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z"}]}
},
"replaceStates": {
"method": "PUT",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]}
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null}]}
},
"purge": {
"method": "POST",