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:
Jérémy M
2023-08-01 00:47:29 +02:00
committed by GitHub
parent b028d9fd2a
commit f111440e00
24 changed files with 547 additions and 30 deletions

View File

@ -5,7 +5,7 @@ module.exports = {
tsconfigRootDir : __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin', 'import'],
plugins: ['@typescript-eslint/eslint-plugin', 'import', 'unused-imports'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
@ -74,5 +74,6 @@ module.exports = {
pathGroupsExcludedImportTypes: ['@nestjs/**'],
},
],
'unused-imports/no-unused-imports': 'warn',
},
};

5
server/@types/common.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;

View File

@ -103,6 +103,7 @@
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-unused-imports": "^3.0.0",
"jest": "28.1.3",
"prettier": "^2.3.2",
"prisma": "4.13.0",

View File

@ -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);
}
}

View 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;
}

View File

@ -7,5 +7,5 @@ import { AuthTokens } from './token.entity';
@ObjectType()
export class Verify extends AuthTokens {
@Field(() => User)
user: Partial<User>;
user: DeepPartial<User>;
}

View File

@ -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,
},
};
}
}

View File

@ -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)

View File

@ -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;

View File

@ -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

View File

@ -21,6 +21,7 @@
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"resolveJsonModule": true
"resolveJsonModule": true,
"typeRoots": ["@types", "node_modules/@types"]
}
}

View File

@ -4776,6 +4776,18 @@ eslint-plugin-prettier@^4.0.0:
dependencies:
prettier-linter-helpers "^1.0.0"
eslint-plugin-unused-imports@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.0.0.tgz#d25175b0072ff16a91892c3aa72a09ca3a9e69e7"
integrity sha512-sduiswLJfZHeeBJ+MQaG+xYzSWdRXoSw61DpU13mzWumCkR0ufD0HmO4kdNokjrkluMHpj/7PJeN35pgbhW3kw==
dependencies:
eslint-rule-composer "^0.3.0"
eslint-rule-composer@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9"
integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==
eslint-scope@5.1.1, eslint-scope@^5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"