ce2d77be2a
## 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>
79 lines
2.3 KiB
TypeScript
79 lines
2.3 KiB
TypeScript
import { Trans } from '@lingui/react';
|
|
import { BaseEmail } from 'src/components/BaseEmail';
|
|
import { MainText } from 'src/components/MainText';
|
|
import { Title } from 'src/components/Title';
|
|
import { createI18nInstance } from 'src/utils/i18n.utils';
|
|
import { type APP_LOCALES } from 'twenty-shared/translations';
|
|
|
|
type ServerAdminAccessChangedEmailProps = {
|
|
actorName: string;
|
|
targetName: string;
|
|
targetEmail: string;
|
|
canAccessFullAdminPanel: boolean;
|
|
canImpersonate: boolean;
|
|
locale: keyof typeof APP_LOCALES;
|
|
};
|
|
|
|
export const ServerAdminAccessChangedEmail = ({
|
|
actorName,
|
|
targetName,
|
|
targetEmail,
|
|
canAccessFullAdminPanel,
|
|
canImpersonate,
|
|
locale,
|
|
}: ServerAdminAccessChangedEmailProps) => {
|
|
const i18n = createI18nInstance(locale);
|
|
const enabledLabel = i18n._('Enabled');
|
|
const disabledLabel = i18n._('Disabled');
|
|
const fullAdminStatus = canAccessFullAdminPanel
|
|
? enabledLabel
|
|
: disabledLabel;
|
|
const impersonateStatus = canImpersonate ? enabledLabel : disabledLabel;
|
|
|
|
return (
|
|
<BaseEmail locale={locale}>
|
|
<Title value={i18n._('Server administrator access changed')} />
|
|
<MainText>
|
|
<Trans
|
|
id="serverAdminAccessChanged.summary"
|
|
message="{actorName} updated server administrator access for {targetName} ({targetEmail})."
|
|
values={{ actorName, targetName, targetEmail }}
|
|
/>
|
|
<br />
|
|
<br />
|
|
<Trans
|
|
id="serverAdminAccessChanged.fullAdmin"
|
|
message="Full admin panel access: {fullAdminStatus}"
|
|
values={{ fullAdminStatus }}
|
|
/>
|
|
<br />
|
|
<Trans
|
|
id="serverAdminAccessChanged.impersonation"
|
|
message="Impersonation: {impersonateStatus}"
|
|
values={{ impersonateStatus }}
|
|
/>
|
|
<br />
|
|
<br />
|
|
<Trans
|
|
id="serverAdminAccessChanged.warning"
|
|
message="If you did not expect this change, review your server administrators immediately."
|
|
/>
|
|
<br />
|
|
</MainText>
|
|
<br />
|
|
<br />
|
|
</BaseEmail>
|
|
);
|
|
};
|
|
|
|
ServerAdminAccessChangedEmail.PreviewProps = {
|
|
actorName: 'John Doe',
|
|
targetName: 'Jane Smith',
|
|
targetEmail: 'jane.smith@example.com',
|
|
canAccessFullAdminPanel: true,
|
|
canImpersonate: false,
|
|
locale: 'en',
|
|
} as ServerAdminAccessChangedEmailProps;
|
|
|
|
export default ServerAdminAccessChangedEmail;
|