feat: implement user impersonation feature (#976)
* feat: wip impersonate user * feat: add ability to impersonate an user * fix: remove console.log * fix: unused import
This commit is contained in:
@ -1,5 +1,9 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@ -7,6 +11,10 @@ import {
|
||||
PrismaSelect,
|
||||
PrismaSelector,
|
||||
} from 'src/decorators/prisma-select.decorator';
|
||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||
import { AuthUser } from 'src/decorators/auth-user.decorator';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { User } from 'src/core/@generated/user/user.model';
|
||||
|
||||
import { AuthTokens } from './dto/token.entity';
|
||||
import { TokenService } from './services/token.service';
|
||||
@ -21,6 +29,7 @@ import { CheckUserExistsInput } from './dto/user-exists.input';
|
||||
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
|
||||
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
|
||||
import { SignUpInput } from './dto/sign-up.input';
|
||||
import { ImpersonateInput } from './dto/impersonate.input';
|
||||
|
||||
@Resolver()
|
||||
export class AuthResolver {
|
||||
@ -96,4 +105,30 @@ export class AuthResolver {
|
||||
|
||||
return { tokens: tokens };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Mutation(() => Verify)
|
||||
async impersonate(
|
||||
@Args() impersonateInput: ImpersonateInput,
|
||||
@AuthUser() user: User,
|
||||
@PrismaSelector({
|
||||
modelName: 'User',
|
||||
defaultFields: {
|
||||
User: {
|
||||
id: true,
|
||||
workspaceMember: { select: { allowImpersonation: true } },
|
||||
},
|
||||
},
|
||||
})
|
||||
prismaSelect: PrismaSelect<'User'>,
|
||||
): Promise<Verify> {
|
||||
// Check if user can impersonate
|
||||
assert(user.canImpersonate, 'User cannot impersonate', ForbiddenException);
|
||||
const select = prismaSelect.valueOf('user') as Prisma.UserSelect & {
|
||||
id: true;
|
||||
workspaceMember: { select: { allowImpersonation: true } };
|
||||
};
|
||||
|
||||
return this.authService.impersonate(impersonateInput.userId, select);
|
||||
}
|
||||
}
|
||||
|
||||
11
server/src/core/auth/dto/impersonate.input.ts
Normal file
11
server/src/core/auth/dto/impersonate.input.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class ImpersonateInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
userId: string;
|
||||
}
|
||||
@ -7,5 +7,5 @@ import { AuthTokens } from './token.entity';
|
||||
@ObjectType()
|
||||
export class Verify extends AuthTokens {
|
||||
@Field(() => User)
|
||||
user: Partial<User>;
|
||||
user: DeepPartial<User>;
|
||||
}
|
||||
|
||||
@ -165,4 +165,41 @@ export class AuthService {
|
||||
|
||||
return { isValid: !!workspace };
|
||||
}
|
||||
|
||||
async impersonate(
|
||||
userId: string,
|
||||
select: Prisma.UserSelect & {
|
||||
id: true;
|
||||
workspaceMember: {
|
||||
select: {
|
||||
allowImpersonation: true;
|
||||
};
|
||||
};
|
||||
},
|
||||
) {
|
||||
const user = await this.userService.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select,
|
||||
});
|
||||
|
||||
assert(user, "This user doesn't exist", NotFoundException);
|
||||
assert(
|
||||
user.workspaceMember?.allowImpersonation,
|
||||
'Impersonation not allowed',
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
const accessToken = await this.tokenService.generateAccessToken(user.id);
|
||||
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ import {
|
||||
import { WorkspaceMemberService } from 'src/core/workspace/services/workspace-member.service';
|
||||
import { DeleteOneWorkspaceMemberArgs } from 'src/core/@generated/workspace-member/delete-one-workspace-member.args';
|
||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||
import { AuthUser } from 'src/decorators/auth-user.decorator';
|
||||
import { User } from 'src/core/@generated/user/user.model';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Resolver(() => WorkspaceMember)
|
||||
@ -48,6 +50,24 @@ export class WorkspaceMemberResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceMember)
|
||||
async allowImpersonation(
|
||||
@Args('allowImpersonation') allowImpersonation: boolean,
|
||||
@AuthUser() user: User,
|
||||
@PrismaSelector({ modelName: 'WorkspaceMember' })
|
||||
prismaSelect: PrismaSelect<'WorkspaceMember'>,
|
||||
): Promise<Partial<WorkspaceMember>> {
|
||||
return this.workspaceMemberService.update({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
data: {
|
||||
allowImpersonation,
|
||||
},
|
||||
select: prismaSelect.value,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceMember)
|
||||
@UseGuards(AbilityGuard)
|
||||
@CheckAbilities(DeleteWorkspaceMemberAbilityHandler)
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "canImpersonate" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_members" ADD COLUMN "allowImpersonation" BOOLEAN NOT NULL DEFAULT true;
|
||||
@ -65,39 +65,42 @@ generator nestgraphql {
|
||||
model User {
|
||||
/// @Validator.IsString()
|
||||
/// @Validator.IsOptional()
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
/// @Validator.IsString()
|
||||
/// @Validator.IsOptional()
|
||||
firstName String?
|
||||
firstName String?
|
||||
/// @Validator.IsString()
|
||||
/// @Validator.IsOptional()
|
||||
lastName String?
|
||||
lastName String?
|
||||
/// @Validator.IsEmail()
|
||||
/// @Validator.IsOptional()
|
||||
email String @unique
|
||||
email String @unique
|
||||
/// @Validator.IsBoolean()
|
||||
/// @Validator.IsOptional()
|
||||
emailVerified Boolean @default(false)
|
||||
emailVerified Boolean @default(false)
|
||||
/// @Validator.IsString()
|
||||
/// @Validator.IsOptional()
|
||||
avatarUrl String?
|
||||
avatarUrl String?
|
||||
/// @Validator.IsString()
|
||||
/// @Validator.IsOptional()
|
||||
locale String
|
||||
locale String
|
||||
/// @Validator.IsString()
|
||||
/// @Validator.IsOptional()
|
||||
phoneNumber String?
|
||||
phoneNumber String?
|
||||
/// @Validator.IsDate()
|
||||
/// @Validator.IsOptional()
|
||||
lastSeen DateTime?
|
||||
lastSeen DateTime?
|
||||
/// @Validator.IsBoolean()
|
||||
/// @Validator.IsOptional()
|
||||
disabled Boolean @default(false)
|
||||
disabled Boolean @default(false)
|
||||
/// @TypeGraphQL.omit(input: true, output: true)
|
||||
passwordHash String?
|
||||
passwordHash String?
|
||||
/// @Validator.IsJSON()
|
||||
/// @Validator.IsOptional()
|
||||
metadata Json?
|
||||
metadata Json?
|
||||
/// @Validator.IsBoolean()
|
||||
/// @Validator.IsOptional()
|
||||
canImpersonate Boolean @default(false)
|
||||
|
||||
/// @TypeGraphQL.omit(input: true)
|
||||
workspaceMember WorkspaceMember?
|
||||
@ -106,17 +109,17 @@ model User {
|
||||
refreshTokens RefreshToken[]
|
||||
comments Comment[]
|
||||
|
||||
authoredActivities Activity[] @relation(name: "authoredActivities")
|
||||
assignedActivities Activity[] @relation(name: "assignedActivities")
|
||||
settings UserSettings @relation(fields: [settingsId], references: [id])
|
||||
settingsId String @unique
|
||||
authoredActivities Activity[] @relation(name: "authoredActivities")
|
||||
assignedActivities Activity[] @relation(name: "assignedActivities")
|
||||
authoredAttachments Attachment[] @relation(name: "authoredAttachments")
|
||||
settings UserSettings @relation(fields: [settingsId], references: [id])
|
||||
settingsId String @unique
|
||||
|
||||
/// @TypeGraphQL.omit(input: true, output: true)
|
||||
deletedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
authoredAttachments Attachment[] @relation(name: "authoredAttachments")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -185,7 +188,10 @@ model Workspace {
|
||||
model WorkspaceMember {
|
||||
/// @Validator.IsString()
|
||||
/// @Validator.IsOptional()
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
/// @Validator.IsBoolean()
|
||||
/// @Validator.IsOptional()
|
||||
allowImpersonation Boolean @default(true)
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @unique
|
||||
|
||||
Reference in New Issue
Block a user