feat(server): in-app server-level admin management (#19785) (#21321)

## Closes #19785

In-app management of **server-level admin rights**
(`canAccessFullAdminPanel`, `canImpersonate`) so self-hosters no longer
need raw SQL + a Redis flush + restart to grant access.

> **Draft** — feature complete; `/code-review` + `/security-review` run
and addressed.

### Background
`AdminPanelGuard` / `ServerLevelImpersonateGuard` read
`request.user.{canAccessFullAdminPanel,canImpersonate}`, hydrated each
request from `CoreEntityCacheService.get('user', …)` (local 30-min +
Redis no-TTL). The cache was only invalidated on soft-delete, so a raw
`UPDATE core."user"` never took effect. The **first** signup auto-gets
both flags; every subsequent admin previously needed raw SQL.

### UX
- **Admin Panel → General → Administrators**: a read-only overview of
every user with server-level access; each row links to that user's admin
page.
- **Find anyone** via the user search (Recent Users) — available to full
admins and impersonators — then open their **admin user page**.
- On the user page, an **"Administrator access"** card (gated on
`canAccessFullAdminPanel`) has two toggles — *Full admin panel access*
and *Impersonation* — that work for **any** user (a user with no access
shows both off). Mirrors how **Impersonate** already works (find user →
user page → act). Each change opens a confirm dialog with a **2FA code**
field; the last full admin's toggle is disabled.

### Backend / security
- **Cache fix** — invalidate the user entity cache on committed user
updates (not just soft-delete) so privilege changes propagate (~100 ms,
cluster-wide) with no restart.
- `getServerAdmins` query + `updateServerAdminAccess` mutation (any
`targetUserId`), gated on `canAccessFullAdminPanel`.
- `NoImpersonationGuard` on both — an impersonated full-admin session
can't be used to escalate an impersonator.
- Fresh **2FA TOTP step-up** (enrolled+verified method **and** a fresh
code; genuine 2FA errors surface; dev-skip on trusted `NODE_ENV`).
- **Last-admin lockout** in a transaction with a pessimistic row lock
(no TOCTOU).
- **Email-to-all-admins + affected user** (rendered once per locale),
structured log, audit event-log emit.
- **Authorization**: the read-only `userLookupAdminPanel` +
`adminPanelRecentUsers` lookups now accept `canAccessFullAdminPanel OR
canImpersonate` (new `AdminPanelOrImpersonateGuard`), so a full admin
without impersonate can still find users to manage.
Workspace/impersonation queries stay impersonate-gated.

### Reviews
- `/code-review` (max effort): 3 security findings
(impersonation-escalation sink, lockout TOCTOU, step-up accepting
PENDING 2FA) — **all fixed**. `/simplify`: applied. `/security-review`:
**no high/medium vulnerabilities**.

### Follow-ups (not in this PR)
- Unit tests for `AdminPanelServerAdminService` + a frontend test.
- Point the self-host troubleshooting docs at the new UI.
- OTP retry UX: `ConfirmationModal` closes on confirm, so a wrong code
needs a reopen (kept to reuse the existing modal; no new pattern).

### Notes for reviewers
- `generated-admin/graphql.ts` entries were hand-added to match codegen
output (admin codegen needs a running server); re-run `nx
graphql:generate twenty-front --configuration=admin` to confirm parity.
- First-admin bootstrap (first signup) is unchanged.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-06-10 06:50:25 +02:00
committed by GitHub
parent 6c65ae8257
commit ce2d77be2a
18 changed files with 1193 additions and 174 deletions
@@ -30,6 +30,10 @@ import {
type IMPERSONATION_EVENT,
type ImpersonationTrackEvent,
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation';
import {
type SERVER_ADMIN_ACCESS_CHANGED_EVENT,
type ServerAdminAccessChangedTrackEvent,
} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/server-admin/server-admin-access-changed';
import {
type USER_SIGNUP_EVENT,
type UserSignupTrackEvent,
@@ -59,7 +63,8 @@ export type TrackEventName =
| typeof OBJECT_RECORD_UPSERTED_EVENT
| typeof USER_SIGNUP_EVENT
| typeof WORKSPACE_CREATED_EVENT
| typeof PAYMENT_RECEIVED_EVENT;
| typeof PAYMENT_RECEIVED_EVENT
| typeof SERVER_ADMIN_ACCESS_CHANGED_EVENT;
export interface TrackEvents {
[CUSTOM_DOMAIN_ACTIVATED_EVENT]: CustomDomainActivatedTrackEvent;
@@ -74,6 +79,7 @@ export interface TrackEvents {
[OBJECT_RECORD_UPSERTED_EVENT]: ObjectRecordUpsertedTrackEvent;
[WORKSPACE_CREATED_EVENT]: WorkspaceCreatedTrackEvent;
[PAYMENT_RECEIVED_EVENT]: PaymentReceivedTrackEvent;
[SERVER_ADMIN_ACCESS_CHANGED_EVENT]: ServerAdminAccessChangedTrackEvent;
}
export type TrackEventProperties<T extends TrackEventName> =
@@ -0,0 +1,25 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
export const SERVER_ADMIN_ACCESS_CHANGED_EVENT =
'ServerAdminAccessChanged' as const;
export const serverAdminAccessChangedSchema = z.strictObject({
event: z.literal(SERVER_ADMIN_ACCESS_CHANGED_EVENT),
properties: z.strictObject({
targetUserId: z.string(),
canAccessFullAdminPanel: z.boolean(),
canImpersonate: z.boolean(),
message: z.string().optional(),
}),
});
export type ServerAdminAccessChangedTrackEvent = z.infer<
typeof serverAdminAccessChangedSchema
>;
registerEvent(
SERVER_ADMIN_ACCESS_CHANGED_EVENT,
serverAdminAccessChangedSchema,
);