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

+19 -8
View File
@@ -60,6 +60,14 @@ To Be Played is split into three repositories:
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
`refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next
refresh. Core checks the same claim.
**Role change**: an admin sets the role of another user on the admin users page (`PUT /api/admin/users/{id}/role`).
The change is a row in `role_change` (actor, old and new role, time; shown in the "Roles" dialog) and in
`admin_audit` (`ROLE_CHANGE`), sets `users.role_changed_at` and clears "view as" of the open logins. An access
token issued before `role_changed_at` counts as not logged in (`401`) at Auth and at Core, within 30 s (cache
TTL); the frontend then refreshes and repeats the request, and the refresh reads the stored role. A role set by
SQL (the first admin) sets no `role_changed_at`; it works from the next refresh. There is never 0 admins: every
write that can remove an admin first locks the active admins (`shared/account/ActiveAdmins`, `SELECT ... FOR
UPDATE`), the actor must still be one of them, and the last admin cannot delete the own account.
- **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or
`@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the
active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked);
@@ -68,8 +76,9 @@ To Be Played is split into three repositories:
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a
service method calling another service's method that needs more.
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth; Core accepts the token
until it expires, max 15 min; the login then answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth and at Core; the login then
answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as
"Delete my account"). No action on the own account
or on another admin. Every action and every "view as" is a row in `admin_audit`.
Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the
right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user
@@ -132,6 +141,8 @@ To Be Played is split into three repositories:
| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` UNCONFIRMED, PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN |
| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`, also for an unconfirmed request) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/role` (`{role, expectedRole}`; `204`; `400` for an unknown role; `404 USER_NOT_FOUND`; `409 SELF_ACTION`, `ACTOR_NOT_ADMIN`, `ROLE_CHANGED` (the role is not `expectedRole`), `ALREADY_DELETED`, `TARGET_IS_BLOCKED`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
| `GET` | `/api/admin/users/{id}/role-changes` (`{results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}`, newest first, 50 at most; `actorEmail` null after the actor is purged) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
@@ -150,7 +161,7 @@ To Be Played is split into three repositories:
| `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token |
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `409 LAST_ADMIN` (the only active admin), `429`) | access token |
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
| `PUT` | `/api/users/settings/appearance` | access token |
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
@@ -194,9 +205,9 @@ may log in and is shown to others" uses `shared/account/ActiveUser`.
A settings change is one `UserRepository.update(userId, change)`: load the user, change the model, write back the
display name and the settings. A new setting needs no new repository method.
Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write,
`activeUsers` (the filter's "blocked or deleted?" check per request) after 30 seconds. Every write evicts what it
changes. A request that read the database just before a block or delete can put the old answer back, so the short
TTL is the real limit: a blocked or deleted user keeps a valid access token for up to 30 s.
`activeUsers` (the filter's "blocked or deleted, and when did the role change?" check per request) after 30 seconds.
Every write evicts what it changes. A request that read the database just before a block or delete can put the old answer back, so the short
TTL is the real limit: a blocked or deleted user, or a token from before a role change, works for up to 30 s.
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
@@ -391,8 +402,8 @@ sequenceDiagram
participant A as Auth
participant C as Core
A->>A: link / unlink / block / unblock / name change (commit)
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked}
C->>C: blocked_user, Steam ID in user_steam_sync, display name in account_name
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt}
C->>C: blocked_user, account_role_change, Steam ID in user_steam_sync, display name in account_name
Note over A,C: nightly 04:15
A->>C: PUT /internal/accounts/states (every user)
```