## 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:
@@ -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> =
|
||||
|
||||
+25
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user