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