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