feat(admin): login-as-user impersonation with audit log

Let super admins act as a user from the user detail dashboard for
support/debugging ("full with guardrails", audit-only).

Backend: AuthService.impersonate issues a short-lived token for the
target carrying an `act` claim (acting admin); refresh preserves it and
JwtStrategy surfaces `impersonatedBy`. Guardrails: cannot impersonate an
admin or a deactivated account; new ImpersonationGuard blocks sensitive
self-service (change own password/phone) while impersonating. New
AuditLog entity records impersonation start/stop (admin, target, ip,
time); admin endpoints POST users/:id/impersonate + .../impersonation/
stop and GET users/:id/audit.

Frontend: lib/impersonation swaps admin/impersonation tokens in
localStorage; persistent banner with exit; "Login as user" button and an
"Admin access log" tab on the detail page; logout clears impersonation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-18 00:49:02 +03:30
parent 7958d2fa72
commit fd38f5659f
17 changed files with 532 additions and 27 deletions
+48 -1
View File
@@ -1,13 +1,19 @@
import {
Controller,
Get,
Post,
Param,
Query,
Req,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { AdminUsersService } from './admin-users.service';
import { AuthService } from '../auth/auth.service';
import { AuditAction } from './entities/audit-log.entity';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
@@ -23,7 +29,10 @@ import { UserRole } from '../common/enums';
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminUsersController {
constructor(private readonly adminUsers: AdminUsersService) {}
constructor(
private readonly adminUsers: AdminUsersService,
private readonly authService: AuthService,
) {}
@Get('users/:id')
@ApiOperation({ summary: 'User overview: profile, status, wallet, revenue, counts' })
@@ -68,4 +77,42 @@ export class AdminUsersController {
getDeploymentLogs(@Param('deploymentId') deploymentId: string) {
return this.adminUsers.getDeploymentLogs(deploymentId);
}
@Get('users/:id/audit')
@ApiOperation({ summary: 'Impersonation audit history for a user' })
getAudit(@Param('id') id: string) {
return this.adminUsers.getAuditForUser(id);
}
@Post('users/:id/impersonate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login as user — issue an impersonation token (logged)' })
async impersonate(@Param('id') id: string, @Req() req: any) {
const result = await this.authService.impersonate(
{ id: req.user.id, role: req.user.role },
id,
);
await this.adminUsers.logImpersonation(
AuditAction.IMPERSONATION_START,
req.user.id,
id,
req.ip,
req.headers?.['user-agent'],
);
return result;
}
@Post('users/:id/impersonation/stop')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Record the end of an impersonation session (logged)' })
async stopImpersonation(@Param('id') id: string, @Req() req: any) {
await this.adminUsers.logImpersonation(
AuditAction.IMPERSONATION_STOP,
req.user.id,
id,
req.ip,
req.headers?.['user-agent'],
);
return { stopped: true };
}
}
+51
View File
@@ -1,4 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsersService } from '../users/users.service';
import { BillingService } from '../billing/billing.service';
import { ApplicationsService } from '../applications/applications.service';
@@ -12,6 +14,7 @@ import {
} from '../common/enums';
import { Application } from '../applications/entities/application.entity';
import { Ticket } from '../tickets/entities/ticket.entity';
import { AuditAction, AuditLog } from './entities/audit-log.entity';
/** How a non-active application can be brought back, for the admin UI. */
type RestoreEligibility = 'restorable' | 'recoverable' | 'none';
@@ -30,8 +33,56 @@ export class AdminUsersService {
private readonly applicationsService: ApplicationsService,
private readonly ticketsService: TicketsService,
private readonly deploymentsService: DeploymentsService,
@InjectRepository(AuditLog)
private readonly auditRepo: Repository<AuditLog>,
) {}
/** Record an impersonation start/stop in the audit trail. */
async logImpersonation(
action: AuditAction,
actorUserId: string,
targetUserId: string,
ip?: string | null,
userAgent?: string | null,
): Promise<void> {
await this.auditRepo.save(
this.auditRepo.create({
action,
actorUserId,
targetUserId,
ip: ip ?? null,
userAgent: userAgent ?? null,
}),
);
}
/** Impersonation audit history for a target user, with admin names resolved. */
async getAuditForUser(userId: string, limit = 50) {
await this.assertUserExists(userId);
const rows = await this.auditRepo.find({
where: { targetUserId: userId },
order: { createdAt: 'DESC' },
take: limit,
});
const actorIds = [...new Set(rows.map((r) => r.actorUserId))];
const actors = actorIds.length
? await this.usersService.findByIds(actorIds)
: [];
const nameById = new Map(
actors.map((a) => [a.id, `${a.firstName ?? ''} ${a.lastName ?? ''}`.trim()]),
);
return rows.map((r) => ({
id: r.id,
action: r.action,
actorUserId: r.actorUserId,
actorName: nameById.get(r.actorUserId) ?? null,
ip: r.ip,
createdAt: r.createdAt,
}));
}
/** Overview tab: profile + account status + wallet + revenue + summary counts. */
async getOverview(userId: string) {
const user = await this.usersService.findById(userId);
+8 -1
View File
@@ -1,24 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { AuditLog } from './entities/audit-log.entity';
import { UsersModule } from '../users/users.module';
import { BillingModule } from '../billing/billing.module';
import { ApplicationsModule } from '../applications/applications.module';
import { DeploymentsModule } from '../deployments/deployments.module';
import { TicketsModule } from '../tickets/tickets.module';
import { AuthModule } from '../auth/auth.module';
/**
* Super-admin aggregation module. Imports the domain modules (which export
* their services) so the admin controller can read everything about a user
* without re-implementing per-module logic.
* without re-implementing per-module logic. Also owns the audit log and the
* impersonation ("login as user") entrypoint, which delegates token minting to
* AuthService.
*/
@Module({
imports: [
TypeOrmModule.forFeature([AuditLog]),
UsersModule,
BillingModule,
ApplicationsModule,
DeploymentsModule,
TicketsModule,
AuthModule,
],
controllers: [AdminUsersController],
providers: [AdminUsersService],
@@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
/** Auditable admin actions. Kept open-ended for future action types. */
export enum AuditAction {
IMPERSONATION_START = 'impersonation_start',
IMPERSONATION_STOP = 'impersonation_stop',
}
/**
* Security audit trail for privileged admin actions. Currently records
* impersonation start/stop so it's always known which admin acted as which
* user and when.
*/
@Entity('audit_logs')
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
// varchar (not enum) to avoid migrations when new action types are added.
@Column({ type: 'varchar' })
action: AuditAction;
/** The admin who performed the action. */
@Column({ type: 'uuid' })
@Index()
actorUserId: string;
/** The user the action targeted (e.g. the impersonated user). */
@Column({ type: 'uuid', nullable: true })
@Index()
targetUserId: string | null;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, unknown> | null;
@Column({ type: 'varchar', nullable: true })
ip: string | null;
@Column({ type: 'varchar', nullable: true })
userAgent: string | null;
@CreateDateColumn()
createdAt: Date;
}
+52 -6
View File
@@ -3,6 +3,8 @@ import {
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@@ -13,7 +15,13 @@ import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { User } from '../users/entities/user.entity';
import { normalizeIranMobile } from '../common/phone.util';
import { OtpMessageKind } from '../common/enums';
import { OtpMessageKind, UserRole } from '../common/enums';
/** The acting admin recorded inside an impersonation token (RFC-8693-style "act"). */
export interface ActClaim {
sub: string; // admin user id
role: string; // admin role at impersonation time
}
/** Returned when an action needs phone verification before tokens are issued. */
export interface VerificationRequired {
@@ -165,12 +173,42 @@ export class AuthService {
throw new UnauthorizedException();
}
return this.generateTokens(user);
// Preserve the impersonation context across refreshes so an admin's
// "login as user" session survives token rotation.
return this.generateTokens(user, payload.act);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
/**
* Issue tokens that authenticate as `targetUserId` while recording the acting
* admin in an `act` claim. Guardrails: the target must exist, be active, and
* not be an admin (no admin-on-admin impersonation). Tokens are short-lived.
*/
async impersonate(admin: { id: string; role: string }, targetUserId: string) {
const target = await this.usersService.findById(targetUserId);
if (!target) {
throw new NotFoundException('User not found');
}
if (!target.isActive) {
throw new BadRequestException('Cannot impersonate a deactivated account');
}
if (target.role === UserRole.ADMIN) {
throw new ForbiddenException('Cannot impersonate an administrator');
}
const tokens = await this.generateTokens(target, {
sub: admin.id,
role: admin.role,
});
return {
user: this.publicUser(target),
...tokens,
impersonation: { by: admin.id, at: new Date().toISOString() },
};
}
private publicUser(user: User) {
return {
id: user.id,
@@ -183,19 +221,27 @@ export class AuthService {
};
}
private async generateTokens(user: User) {
private async generateTokens(user: User, act?: ActClaim) {
// `email` may be null; use phone as the human-readable identity claim.
const payload = {
const payload: Record<string, unknown> = {
sub: user.id,
email: user.email ?? user.phone,
role: user.role,
};
// Impersonation sessions carry the acting admin and are deliberately short-lived.
if (act) {
payload.act = act;
}
const accessExpiresIn = act ? '30m' : this.configService.get('jwt.expiresIn');
const refreshExpiresIn = act
? '30m'
: this.configService.get('jwt.refreshExpiresIn');
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
this.jwtService.signAsync(payload, { expiresIn: accessExpiresIn }),
this.jwtService.signAsync(payload, {
secret: this.configService.get('jwt.refreshSecret'),
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
expiresIn: refreshExpiresIn,
}),
]);
@@ -7,6 +7,8 @@ interface JwtPayload {
sub: string;
email: string;
role: string;
/** Present on impersonation tokens: the acting admin. */
act?: { sub: string; role: string };
}
@Injectable()
@@ -24,6 +26,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
id: payload.sub,
email: payload.email,
role: payload.role,
// Non-null only while an admin is impersonating this user.
impersonatedBy: payload.act?.sub ?? null,
impersonatorRole: payload.act?.role ?? null,
};
}
}
@@ -0,0 +1,27 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
/**
* Blocks sensitive self-service actions while an admin is impersonating the
* user (token carries an `act` claim, surfaced as `req.user.impersonatedBy`).
* Apply to endpoints like changing one's own password/phone — operations an
* admin acting "as" a user must never perform on their behalf.
*
* Runs after AuthGuard('jwt'), so `req.user` is already populated.
*/
@Injectable()
export class ImpersonationGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.impersonatedBy) {
throw new ForbiddenException(
'This action is not allowed while impersonating a user',
);
}
return true;
}
}
+4
View File
@@ -15,6 +15,7 @@ import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'cla
import { UsersService } from './users.service';
import { VerificationService } from './verification.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { ImpersonationGuard } from '../common/guards/impersonation.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
@@ -132,6 +133,7 @@ export class UsersController {
}
@Post('me/password')
@UseGuards(ImpersonationGuard)
@ApiOperation({ summary: 'Change own password (requires current password)' })
async changePassword(@Request() req: any, @Body() dto: ChangePasswordDto) {
await this.usersService.changeOwnPassword(
@@ -143,6 +145,7 @@ export class UsersController {
}
@Post('me/phone/request')
@UseGuards(ImpersonationGuard)
@ApiOperation({
summary: 'Start phone change — sends an OTP to the new number',
})
@@ -158,6 +161,7 @@ export class UsersController {
}
@Post('me/phone/confirm')
@UseGuards(ImpersonationGuard)
@ApiOperation({ summary: 'Confirm phone change with the OTP' })
async confirmPhoneChange(@Request() req: any, @Body() dto: ConfirmCodeDto) {
return this.verificationService.confirmPhoneChange(req.user.id, dto.code);
+7 -1
View File
@@ -7,7 +7,7 @@ import {
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike, LessThan } from 'typeorm';
import { Repository, ILike, LessThan, In } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { UserRole } from '../common/enums';
@@ -105,6 +105,12 @@ export class UsersService {
return this.usersRepository.findOne({ where: { id } });
}
/** Batch lookup by id — used to resolve actor names for audit logs. */
async findByIds(ids: string[]): Promise<User[]> {
if (!ids.length) return [];
return this.usersRepository.find({ where: { id: In(ids) } });
}
/** True when `plain` matches the user's stored password hash. */
async verifyPassword(user: User, plain: string): Promise<boolean> {
return bcrypt.compare(plain, user.password);