Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement deleteUsers mutation #10498

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"CreateStripeSubscriptionSuccess": "./types/CreateStripeSubscriptionSuccess#CreateStripeSubscriptionSuccessSource",
"CreateTaskPayload": "./types/CreateTaskPayload#CreateTaskPayloadSource",
"DeleteCommentSuccess": "./types/DeleteCommentSuccess#DeleteCommentSuccessSource",
"DeleteUsersSuccess": "./types/DeleteUsersSuccess#DeleteUsersSuccessSource",
"Discussion": "../../postgres/types/index#Discussion",
"DomainJoinRequest": "../../database/types/DomainJoinRequest#default as DomainJoinRequestDB",
"EndTeamPromptSuccess": "./types/EndTeamPromptSuccess#EndTeamPromptSuccessSource",
Expand Down
36 changes: 36 additions & 0 deletions packages/client/mutations/DeleteUsersMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import graphql from 'babel-plugin-relay/macro'
import {commitMutation} from 'react-relay'
import {DeleteUsersMutation as TDeleteUsersMutation} from '../__generated__/DeleteUsersMutation.graphql'
import {StandardMutation} from '../types/relayMutations'

graphql`
fragment DeleteUsersMutation_users on DeleteUsersSuccess {
deletedUsers {
id
email
isRemoved
}
}
`

const mutation = graphql`
mutation DeleteUsersMutation($emails: [Email!]!) {
deleteUsers(emails: $emails) {
... on ErrorPayload {
error {
message
}
}
...DeleteUsersMutation_users @relay(mask: false)
}
}
`

const DeleteUsersMutation: StandardMutation<TDeleteUsersMutation> = (atmosphere, variables) => {
return commitMutation<TDeleteUsersMutation>(atmosphere, {
mutation,
variables
})
}

export default DeleteUsersMutation
63 changes: 63 additions & 0 deletions packages/server/__tests__/deleteUsers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {sendIntranet, signUp} from './common'

test('Delete users by email', async () => {
const user1 = await signUp()
const user2 = await signUp()
const emails = [user1.email, user2.email]
const userIds = [user1.userId, user2.userId]

const deleteUsers = await sendIntranet({
query: `
mutation DeleteUsers($emails: [Email!]!) {
deleteUsers(emails: $emails) {
... on ErrorPayload {
error {
message
}
}
... on DeleteUsersSuccess {
deletedUsers {
id
email
isRemoved
}
}
}
}
`,
variables: {
emails
}
})

expect(deleteUsers.data.deleteUsers.deletedUsers).toHaveLength(2)

// Verify both users were deleted
for (const userId of userIds) {
const user = await sendIntranet({
query: `
query User($userId: ID!) {
user(userId: $userId) {
id
isRemoved
email
}
}
`,
variables: {
userId
},
isPrivate: true
})

expect(user).toMatchObject({
data: {
user: {
id: userId,
isRemoved: true,
email: expect.not.stringMatching(emails[userIds.indexOf(userId)] || '')
}
}
})
}
}, 40000)
2 changes: 1 addition & 1 deletion packages/server/graphql/mutations/deleteUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import DeleteUserPayload from '../types/DeleteUserPayload'
import sendAccountRemovedEvent from './helpers/sendAccountRemovedEvent'
import softDeleteUser from './helpers/softDeleteUser'

const markUserSoftDeleted = async (
export const markUserSoftDeleted = async (
userIdToDelete: string,
deletedUserEmail: string,
validReason: string
Expand Down
100 changes: 100 additions & 0 deletions packages/server/graphql/public/mutations/deleteUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {USER_BATCH_DELETE_LIMIT} from '../../../postgres/constants'
import {getUsersByEmails} from '../../../postgres/queries/getUsersByEmails'
import {getUserById} from '../../../postgres/queries/getUsersByIds'
import {
getUserId,
isSuperUser,
isUserBillingLeader,
isUserOrgAdmin
} from '../../../utils/authorization'
import getDomainFromEmail from '../../../utils/getDomainFromEmail'
import {GQLContext} from '../../graphql'
import {markUserSoftDeleted} from '../../mutations/deleteUser'
import softDeleteUser from '../../mutations/helpers/softDeleteUser'
import {MutationResolvers} from '../resolverTypes'

const deleteUsers: MutationResolvers['deleteUsers'] = async (
_source,
{emails}: {emails: string[]},
{authToken, dataLoader}: GQLContext
) => {
if (emails.length === 0) {
return {error: {message: 'No emails provided'}}
}

if (emails.length > USER_BATCH_DELETE_LIMIT) {
return {error: {message: `Cannot delete more than ${USER_BATCH_DELETE_LIMIT} users at once`}}
}

const su = isSuperUser(authToken)
const viewerId = getUserId(authToken)
const viewer = await getUserById(viewerId)
if (!viewer) return {error: {message: 'Invalid viewer'}}

const usersToDelete = await getUsersByEmails(emails)
if (usersToDelete.length === 0) {
return {error: {message: 'No valid users found to delete'}}
} else if (usersToDelete.length !== emails.length) {
const missingEmails = emails.filter(
(email) => !usersToDelete.some((user) => user.email === email)
)
return {error: {message: `Some users were not found: ${missingEmails.join(', ')}`}}
}

// First check all permissions before making any changes
const viewerOrgUsers = await dataLoader.get('organizationUsersByUserId').load(viewerId)
const permissionChecks = await Promise.all(
usersToDelete.map(async (userToDelete) => {
// Super users can delete anyone
if (su) return {userId: userToDelete.id, hasPermission: true}

const orgUsers = await dataLoader.get('organizationUsersByUserId').load(userToDelete.id)

// Check permissions for each org the user belongs to
const hasOrgPermission = await Promise.all(
orgUsers.map(async ({orgId}) => {
const viewerOrgUser = viewerOrgUsers.find((vu) => vu.orgId === orgId)
if (!viewerOrgUser) return false

const [isOrgAdmin, isBillingLeader] = await Promise.all([
isUserOrgAdmin(viewerId, orgId, dataLoader),
isUserBillingLeader(viewerId, orgId, dataLoader)
])

if (!(isOrgAdmin || isBillingLeader)) return false

const organization = await dataLoader.get('organizations').loadNonNull(orgId)
return organization.activeDomain === getDomainFromEmail(userToDelete.email)
Comment on lines +66 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1 This is not enough for owning a domain. We can only allow to delete users fully for organizations with verified domains. This usually means just enterprise orgs with SAML, but we can manually verify domains for them without them using SAML.

})
)

return {
userId: userToDelete.id,
hasPermission: hasOrgPermission.some(Boolean)
}
})
)

// Check if we have permission to delete ALL users
const unauthorizedUsers = usersToDelete.filter((_, idx) => !permissionChecks[idx]!.hasPermission)
if (unauthorizedUsers.length > 0) {
return {
error: {
message: `You don't have permission to remove the following users: ${unauthorizedUsers.map((user) => user.email).join(', ')}`
}
}
}

// If we have permission for all users, perform the deletions
const deletedUserIds = await Promise.all(
permissionChecks.map(async ({userId}) => {
const deletedUserEmail = await softDeleteUser(userId, dataLoader)
await markUserSoftDeleted(userId, deletedUserEmail, 'Mass user deletion')
return userId
})
)

return {deletedUserIds}
}

export default deleteUsers
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
union DeleteUsersPayload = ErrorPayload | DeleteUsersSuccess
11 changes: 11 additions & 0 deletions packages/server/graphql/public/typeDefs/DeleteUsersSuccess.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type DeleteUsersSuccess {
"""
the ids of the deleted users
"""
deletedUserIds: [ID!]

"""
the deleted users
"""
deletedUsers: [User!]
}
10 changes: 10 additions & 0 deletions packages/server/graphql/public/typeDefs/Mutation.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,16 @@ type Mutation {
reason: String
): DeleteUserPayload!

"""
Delete a list of users, removing them from all teams and orgs
"""
deleteUsers(
"""
a list of user emails
"""
emails: [Email!]!
): DeleteUsersPayload!

"""
Deny a user from joining via push invitation
"""
Expand Down
15 changes: 15 additions & 0 deletions packages/server/graphql/public/types/DeleteUsersSuccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import isValid from '../../isValid'
import {DeleteUsersSuccessResolvers} from '../resolverTypes'

export type DeleteUsersSuccessSource = {
deletedUserIds: string[]
}

const DeleteUsersSuccess: DeleteUsersSuccessResolvers = {
deletedUsers: async ({deletedUserIds}, _args, {dataLoader}) => {
const users = (await dataLoader.get('users').loadMany(deletedUserIds)).filter(isValid)
return users
}
}

export default DeleteUsersSuccess
1 change: 1 addition & 0 deletions packages/server/postgres/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export const USER_EMAIL_LIMIT = 500
export const USER_PREFERRED_NAME_LIMIT = 100
export const USER_OVERLIMIT_COPY_LIMIT = 500
export const USER_REASON_REMOVED_LIMIT = 2000
export const USER_BATCH_DELETE_LIMIT = 100
tianrunhe marked this conversation as resolved.
Show resolved Hide resolved

export const TEAM_NAME_LIMIT = 100
Loading