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
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { AdminPanelApplicationRegistrationResolver } from 'src/engine/core-modules/admin-panel/admin-panel-application-registration.resolver';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
@@ -15,6 +16,7 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelServerAdminService } from 'src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service';
import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service';
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
@@ -25,6 +27,7 @@ import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
@@ -35,6 +38,7 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { TwoFactorAuthenticationModule } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.module';
import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module';
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -77,11 +81,15 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
UpgradeModule,
UserModule,
JwtModule,
CoreEntityCacheModule,
EventLogEmitterModule,
TwoFactorAuthenticationModule,
],
providers: [
AdminPanelResolver,
AdminPanelApplicationRegistrationResolver,
AdminPanelUserLookupService,
AdminPanelServerAdminService,
AdminPanelStatisticsService,
AdminPanelBillingService,
AdminPanelChatService,
@@ -26,9 +26,11 @@ import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/
import { QueueJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/queue-jobs-response.dto';
import { RetryJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/retry-jobs-response.dto';
import { RevokeSigningKeyInput } from 'src/engine/core-modules/admin-panel/dtos/revoke-signing-key.input';
import { ServerAdminDTO } from 'src/engine/core-modules/admin-panel/dtos/server-admin.dto';
import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto';
import { SigningKeysAdminPanelDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto';
import { SystemHealthDTO } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
import { UpdateServerAdminAccessInput } from 'src/engine/core-modules/admin-panel/dtos/update-server-admin-access.input';
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
@@ -41,6 +43,7 @@ import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/se
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service';
import { AdminPanelServerAdminService } from 'src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service';
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
@@ -50,6 +53,7 @@ import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modu
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { AdminAiModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
import { FeatureFlagException } from 'src/engine/core-modules/feature-flag/feature-flag.exception';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@@ -60,10 +64,15 @@ import { type MessageQueue } from 'src/engine/core-modules/message-queue/message
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter';
import { UsageBreakdownItemDTO } from 'src/engine/core-modules/usage/dtos/usage-breakdown-item.dto';
import { UsageAnalyticsService } from 'src/engine/core-modules/usage/services/usage-analytics.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { AdminPanelOrImpersonateGuard } from 'src/engine/guards/admin-panel-or-impersonate.guard';
import { NoImpersonationGuard } from 'src/engine/guards/no-impersonation.guard';
import { ServerLevelImpersonateGuard } from 'src/engine/guards/server-level-impersonate.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
@@ -89,6 +98,7 @@ import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input';
@AdminResolver()
@UseFilters(
AuthGraphqlApiExceptionFilter,
TwoFactorAuthenticationExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
ConfigVariableGraphqlApiExceptionFilter,
)
@@ -100,6 +110,7 @@ import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input';
export class AdminPanelResolver {
constructor(
private readonly adminUserLookupService: AdminPanelUserLookupService,
private readonly adminServerAdminService: AdminPanelServerAdminService,
private readonly adminStatisticsService: AdminPanelStatisticsService,
private readonly adminBillingService: AdminPanelBillingService,
private readonly adminChatService: AdminPanelChatService,
@@ -123,7 +134,7 @@ export class AdminPanelResolver {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
@UseGuards(ServerLevelImpersonateGuard)
@UseGuards(AdminPanelOrImpersonateGuard)
@Query(() => UserLookup)
async userLookupAdminPanel(
@Args() userLookupInput: UserLookupInput,
@@ -133,7 +144,7 @@ export class AdminPanelResolver {
);
}
@UseGuards(ServerLevelImpersonateGuard)
@UseGuards(AdminPanelOrImpersonateGuard)
@Query(() => [AdminPanelRecentUserDTO])
async adminPanelRecentUsers(
@Args('searchTerm', {
@@ -159,6 +170,29 @@ export class AdminPanelResolver {
return this.adminStatisticsService.getTopWorkspaces(searchTerm);
}
@UseGuards(AdminPanelGuard, NoImpersonationGuard)
@Query(() => [ServerAdminDTO])
async getServerAdmins(): Promise<ServerAdminDTO[]> {
return this.adminServerAdminService.getServerAdmins();
}
@UseGuards(AdminPanelGuard, NoImpersonationGuard)
@Mutation(() => ServerAdminDTO)
async updateServerAdminAccess(
@Args() input: UpdateServerAdminAccessInput,
@AuthUser() actor: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ServerAdminDTO> {
return this.adminServerAdminService.updateServerAdminAccess({
actor,
actorWorkspaceId: workspace.id,
targetUserId: input.userId,
canAccessFullAdminPanel: input.canAccessFullAdminPanel,
canImpersonate: input.canImpersonate,
otp: input.otp,
});
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async updateWorkspaceFeatureFlag(
@@ -0,0 +1,24 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ServerAdmin')
export class ServerAdminDTO {
@Field(() => UUIDScalarType)
id: string;
@Field()
email: string;
@Field()
firstName: string;
@Field()
lastName: string;
@Field()
canAccessFullAdminPanel: boolean;
@Field()
canImpersonate: boolean;
}
@@ -0,0 +1,34 @@
import { ArgsType, Field } from '@nestjs/graphql';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ArgsType()
export class UpdateServerAdminAccessInput {
@Field(() => UUIDScalarType)
@IsNotEmpty()
@IsUUID()
userId: string;
@Field({ nullable: true })
@IsOptional()
@IsBoolean()
canAccessFullAdminPanel?: boolean;
@Field({ nullable: true })
@IsOptional()
@IsBoolean()
canImpersonate?: boolean;
@Field({ nullable: true })
@IsOptional()
@IsString()
otp?: string;
}
@@ -0,0 +1,319 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { isNonEmptyString } from '@sniptt/guards';
import { ServerAdminAccessChangedEmail } from 'twenty-emails';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { type ServerAdminDTO } from 'src/engine/core-modules/admin-panel/dtos/server-admin.dto';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { SERVER_ADMIN_ACCESS_CHANGED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/server-admin/server-admin-access-changed';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service';
import { twoFactorAuthenticationMethodsValidator } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.validation';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class AdminPanelServerAdminService {
private readonly logger = new Logger(AdminPanelServerAdminService.name);
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly coreEntityCacheService: CoreEntityCacheService,
private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
private readonly emailService: EmailService,
private readonly i18nService: I18nService,
private readonly twentyConfigService: TwentyConfigService,
private readonly eventLogEmitterService: EventLogEmitterService,
) {}
async getServerAdmins(): Promise<ServerAdminDTO[]> {
const admins = await this.userRepository.find({
where: [{ canAccessFullAdminPanel: true }, { canImpersonate: true }],
order: { firstName: 'ASC', lastName: 'ASC' },
});
return admins.map((admin) => this.toServerAdminDTO(admin));
}
async updateServerAdminAccess({
actor,
actorWorkspaceId,
targetUserId,
canAccessFullAdminPanel,
canImpersonate,
otp,
}: {
actor: AuthContextUser;
actorWorkspaceId: string;
targetUserId: string;
canAccessFullAdminPanel?: boolean;
canImpersonate?: boolean;
otp?: string;
}): Promise<ServerAdminDTO> {
if (!isDefined(canAccessFullAdminPanel) && !isDefined(canImpersonate)) {
throw new UserInputError('No administrator access change was provided.');
}
const targetUser = await this.userRepository.findOne({
where: { id: targetUserId },
});
if (!isDefined(targetUser)) {
throw new UserInputError('User not found.');
}
await this.assertFreshStepUpAuthentication({
actorUserId: actor.id,
actorWorkspaceId,
otp,
});
const nextCanAccessFullAdminPanel =
canAccessFullAdminPanel ?? targetUser.canAccessFullAdminPanel;
const nextCanImpersonate = canImpersonate ?? targetUser.canImpersonate;
const hasChange =
nextCanAccessFullAdminPanel !== targetUser.canAccessFullAdminPanel ||
nextCanImpersonate !== targetUser.canImpersonate;
if (!hasChange) {
return this.toServerAdminDTO(targetUser);
}
const isRevokingFullAdmin =
targetUser.canAccessFullAdminPanel === true &&
nextCanAccessFullAdminPanel === false;
targetUser.canAccessFullAdminPanel = nextCanAccessFullAdminPanel;
targetUser.canImpersonate = nextCanImpersonate;
await this.userRepository.manager.transaction(async (manager) => {
if (isRevokingFullAdmin) {
const lockedFullAdmins = await manager.find(UserEntity, {
where: { canAccessFullAdminPanel: true },
lock: { mode: 'pessimistic_write' },
});
const otherFullAdmins = lockedFullAdmins.filter(
(admin) => admin.id !== targetUserId,
);
if (otherFullAdmins.length === 0) {
throw new UserInputError(
'You cannot revoke admin panel access from the last server administrator.',
{
userFriendlyMessage: msg`You cannot revoke admin panel access from the last server administrator.`,
},
);
}
}
await manager.save(UserEntity, targetUser);
});
await this.coreEntityCacheService.invalidate('user', targetUserId);
this.logger.log(
`Server admin access for user ${targetUserId} updated by ${actor.id}: ` +
`canAccessFullAdminPanel=${nextCanAccessFullAdminPanel}, canImpersonate=${nextCanImpersonate}`,
);
this.emitServerAdminAccessChangedEvent({
actor,
actorWorkspaceId,
targetUser,
});
await this.notifyAdministrators({ actor, targetUser });
return this.toServerAdminDTO(targetUser);
}
private async assertFreshStepUpAuthentication({
actorUserId,
actorWorkspaceId,
otp,
}: {
actorUserId: string;
actorWorkspaceId: string;
otp?: string;
}): Promise<void> {
const isDevelopment =
this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT;
if (isDevelopment) {
return;
}
if (!isNonEmptyString(otp)) {
throw new UserInputError(
'A two-factor authentication code is required to change server administrator access.',
{
userFriendlyMessage: msg`Enter your two-factor authentication code to manage server administrators.`,
},
);
}
// Verify against the actor's current workspace only — checking the same code
// against every workspace they belong to would allow one OTP guess per
// workspace, weakening brute-force resistance.
const actorUserWorkspace = await this.userWorkspaceRepository.findOne({
where: { userId: actorUserId, workspaceId: actorWorkspaceId },
relations: ['twoFactorAuthenticationMethods'],
});
const hasVerifiedTwoFactor =
isDefined(actorUserWorkspace) &&
twoFactorAuthenticationMethodsValidator.areDefined(
actorUserWorkspace.twoFactorAuthenticationMethods,
) &&
twoFactorAuthenticationMethodsValidator.areVerified(
actorUserWorkspace.twoFactorAuthenticationMethods,
);
if (!hasVerifiedTwoFactor) {
throw new UserInputError(
'Enable two-factor authentication in your current workspace to manage server administrators.',
{
userFriendlyMessage: msg`Enable two-factor authentication in your current workspace to manage server administrators.`,
},
);
}
// A wrong code throws INVALID_OTP, which the resolver's
// TwoFactorAuthenticationExceptionFilter maps to a user-friendly message.
await this.twoFactorAuthenticationService.verifyTwoFactorAuthenticationMethodForAuthenticatedUser(
actorUserId,
otp,
actorWorkspaceId,
);
}
private async notifyAdministrators({
actor,
targetUser,
}: {
actor: AuthContextUser;
targetUser: UserEntity;
}): Promise<void> {
try {
const fullAdmins = await this.userRepository.find({
where: { canAccessFullAdminPanel: true },
});
const recipientsById = new Map<string, UserEntity>();
for (const fullAdmin of fullAdmins) {
recipientsById.set(fullAdmin.id, fullAdmin);
}
recipientsById.set(targetUser.id, targetUser);
const actorName = `${actor.firstName} ${actor.lastName}`.trim();
const targetName =
`${targetUser.firstName} ${targetUser.lastName}`.trim();
const from = `${this.twentyConfigService.get('EMAIL_FROM_NAME')} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`;
const recipientsByLocale = new Map<UserEntity['locale'], UserEntity[]>();
for (const recipient of recipientsById.values()) {
const locale = recipient.locale || SOURCE_LOCALE;
const localeRecipients = recipientsByLocale.get(locale) ?? [];
localeRecipients.push(recipient);
recipientsByLocale.set(locale, localeRecipients);
}
await Promise.allSettled(
Array.from(recipientsByLocale.entries()).map(
async ([locale, recipients]) => {
const emailTemplate = ServerAdminAccessChangedEmail({
actorName,
targetName,
targetEmail: targetUser.email,
canAccessFullAdminPanel: targetUser.canAccessFullAdminPanel,
canImpersonate: targetUser.canImpersonate,
locale,
});
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const i18n = this.i18nService.getI18nInstance(locale);
const subject = i18n._(msg`Server administrator access changed`);
const sendResults = await Promise.allSettled(
recipients.map((recipient) =>
this.emailService.send({
from,
to: recipient.email,
subject,
text,
html,
}),
),
);
const failedCount = sendResults.filter(
(result) => result.status === 'rejected',
).length;
if (failedCount > 0) {
this.logger.error(
`Failed to enqueue ${failedCount} server admin access notification email(s) for locale ${locale}`,
);
}
},
),
);
} catch (error) {
this.logger.error(
'Failed to send server admin access change notifications',
error,
);
}
}
private emitServerAdminAccessChangedEvent({
actor,
actorWorkspaceId,
targetUser,
}: {
actor: AuthContextUser;
actorWorkspaceId: string;
targetUser: UserEntity;
}): void {
void this.eventLogEmitterService
.createContext({ workspaceId: actorWorkspaceId, userId: actor.id })
.insertWorkspaceEvent(SERVER_ADMIN_ACCESS_CHANGED_EVENT, {
targetUserId: targetUser.id,
canAccessFullAdminPanel: targetUser.canAccessFullAdminPanel,
canImpersonate: targetUser.canImpersonate,
message: `Server admin access for user ${targetUser.id} changed by ${actor.id}`,
});
}
private toServerAdminDTO(user: UserEntity): ServerAdminDTO {
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
canAccessFullAdminPanel: user.canAccessFullAdminPanel,
canImpersonate: user.canImpersonate,
};
}
}
@@ -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,
);
@@ -446,9 +446,15 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
user.isEmailVerified = true;
return queryRunner
const savedUser = queryRunner
? await queryRunner.manager.save(UserEntity, user)
: await this.userRepository.save(user);
if (!queryRunner) {
await this.coreEntityCacheService.invalidate('user', userId);
}
return savedUser;
}
async updateEmailFromVerificationToken(userId: string, email: string) {
@@ -458,6 +464,8 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
const updatedUser = await this.userRepository.save(user);
await this.coreEntityCacheService.invalidate('user', user.id);
await this.enqueueWorkspaceMemberEmailUpdate({
userId: user.id,
email,
@@ -0,0 +1,17 @@
import { type CanActivate, type ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
// Read-only admin-panel lookups (user/recent-users search) are available to
// full admins as well as impersonators: managing server-admin access requires
// finding users, and a full admin is the higher privilege.
export class AdminPanelOrImpersonateGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
return (
request.user.canAccessFullAdminPanel === true ||
request.user.canImpersonate === true
);
}
}