Skip to content

Commit 0e897af

Browse files
authored
[server] fix deleteUser permissions (#18989)
1 parent 5d5b068 commit 0e897af

File tree

5 files changed

+123
-86
lines changed

5 files changed

+123
-86
lines changed

components/server/src/user/user-deletion-service.ts

Lines changed: 15 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,48 +5,33 @@
55
*/
66

77
import { injectable, inject } from "inversify";
8-
import { UserDB, WorkspaceDB, TeamDB, ProjectDB } from "@gitpod/gitpod-db/lib";
8+
import { WorkspaceDB, TeamDB, ProjectDB } from "@gitpod/gitpod-db/lib";
99
import { User, Workspace } from "@gitpod/gitpod-protocol";
1010
import { StorageClient } from "../storage/storage-client";
1111
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
1212
import { StopWorkspacePolicy } from "@gitpod/ws-manager/lib";
1313
import { AuthProviderService } from "../auth/auth-provider-service";
14-
import { IAnalyticsWriter } from "@gitpod/gitpod-protocol/lib/analytics";
1514
import { WorkspaceService } from "../workspace/workspace-service";
16-
import { Authorizer } from "../authorization/authorizer";
17-
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
15+
import { UserService } from "./user-service";
16+
import { TransactionalContext } from "@gitpod/gitpod-db/lib/typeorm/transactional-db-impl";
1817

1918
@injectable()
2019
export class UserDeletionService {
2120
constructor(
22-
@inject(UserDB) private readonly db: UserDB,
21+
@inject(UserService) private readonly userService: UserService,
2322
@inject(WorkspaceDB) private readonly workspaceDb: WorkspaceDB,
2423
@inject(TeamDB) private readonly teamDb: TeamDB,
2524
@inject(ProjectDB) private readonly projectDb: ProjectDB,
2625
@inject(StorageClient) private readonly storageClient: StorageClient,
2726
@inject(WorkspaceService) private readonly workspaceService: WorkspaceService,
2827
@inject(AuthProviderService) private readonly authProviderService: AuthProviderService,
29-
@inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter,
30-
@inject(Authorizer) private readonly authorizer: Authorizer,
31-
) {}
32-
33-
/**
34-
* This method deletes a User logically. The contract here is that after running this method without receiving an
35-
* error, the system does not contain any data that is relatable to the actual person in the sense of the GDPR.
36-
* To guarantee that, but also maintain traceability
37-
* we anonymize data that might contain user related/relatable data and keep the entities itself (incl. ids).
38-
*/
39-
async deleteUser(userId: string, targetUserId: string): Promise<void> {
40-
await this.authorizer.checkPermissionOnUser(userId, "delete", targetUserId);
41-
const user = await this.db.findUserById(targetUserId);
42-
if (!user) {
43-
throw new ApplicationError(ErrorCodes.NOT_FOUND, `No user with id ${targetUserId} found!`);
44-
}
45-
46-
if (user.markedDeleted === true) {
47-
log.debug({ userId: targetUserId }, "Is deleted but markDeleted already set. Continuing.");
48-
}
28+
) {
29+
this.userService.onDeleteUser(async (subjectId, user, ctx) => {
30+
await this.contributeToDeleteUser(subjectId, user, ctx);
31+
});
32+
}
4933

34+
private async contributeToDeleteUser(userId: string, user: User, ctx: TransactionalContext): Promise<void> {
5035
// Stop all workspaces
5136
await this.workspaceService.stopRunningWorkspacesForUser(
5237
{},
@@ -62,70 +47,20 @@ export class UserDeletionService {
6247
try {
6348
await this.authProviderService.deleteAuthProvider(provider);
6449
} catch (error) {
65-
log.error({ userId: targetUserId }, "Failed to delete user's auth provider.", error);
50+
log.error({ userId: user.id }, "Failed to delete user's auth provider.", error);
6651
}
6752
}
6853

69-
// User
70-
await this.db.transaction(async (db) => {
71-
this.anonymizeUser(user);
72-
this.deleteIdentities(user);
73-
await this.deleteTokens(db, user);
74-
user.lastVerificationTime = undefined;
75-
user.markedDeleted = true;
76-
await db.storeUser(user);
77-
});
78-
7954
await Promise.all([
8055
// Workspace
81-
this.anonymizeAllWorkspaces(targetUserId),
56+
this.anonymizeAllWorkspaces(user.id),
8257
// Bucket
83-
this.deleteUserBucket(targetUserId),
58+
this.deleteUserBucket(user.id),
8459
// Teams owned only by this user
85-
this.deleteSoleOwnedTeams(targetUserId),
60+
this.deleteSoleOwnedTeams(user.id),
8661
// Team memberships
87-
this.deleteTeamMemberships(targetUserId),
62+
this.deleteTeamMemberships(user.id),
8863
]);
89-
90-
// Track the deletion Event for Analytics Purposes
91-
this.analytics.track({
92-
userId: user.id,
93-
event: "deletion",
94-
properties: {
95-
deleted_at: new Date().toISOString(),
96-
},
97-
});
98-
this.analytics.identify({
99-
userId: user.id,
100-
traits: {
101-
github_slug: "deleted-user",
102-
gitlab_slug: "deleted-user",
103-
bitbucket_slug: "deleted-user",
104-
email: "deleted-user",
105-
full_name: "deleted-user",
106-
name: "deleted-user",
107-
},
108-
});
109-
}
110-
111-
private anonymizeUser(user: User) {
112-
user.avatarUrl = "deleted-avatarUrl";
113-
user.fullName = "deleted-fullName";
114-
user.name = "deleted-Name";
115-
if (user.verificationPhoneNumber) {
116-
user.verificationPhoneNumber = "deleted-phoneNumber";
117-
}
118-
}
119-
120-
private deleteIdentities(user: User) {
121-
for (const identity of user.identities) {
122-
identity.deleted = true; // This triggers the HARD DELETION of the identity
123-
}
124-
}
125-
126-
private async deleteTokens(db: UserDB, user: User) {
127-
const tokenDeletions = user.identities.map((identity) => db.deleteTokens(identity));
128-
await Promise.all(tokenDeletions);
12964
}
13065

13166
private async anonymizeAllWorkspaces(userId: string) {

components/server/src/user/user-service.spec.db.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const expect = chai.expect;
2323
describe("UserService", async () => {
2424
let container: Container;
2525
let userService: UserService;
26+
let orgService: OrganizationService;
2627
let auth: Authorizer;
2728
let org: Organization;
2829
let user: User;
@@ -36,7 +37,7 @@ describe("UserService", async () => {
3637
});
3738
userService = container.get<UserService>(UserService);
3839
auth = container.get(Authorizer);
39-
const orgService = container.get<OrganizationService>(OrganizationService);
40+
orgService = container.get<OrganizationService>(OrganizationService);
4041
org = await orgService.createOrganization(BUILTIN_INSTLLATION_ADMIN_USER_ID, "myOrg");
4142
const invite = await orgService.getOrCreateInvite(BUILTIN_INSTLLATION_ADMIN_USER_ID, org.id);
4243
user = await userService.createUser({
@@ -203,4 +204,35 @@ describe("UserService", async () => {
203204
expect(users.total).to.eq(1);
204205
expect(users.rows.some((u) => u.id === nonOrgUser.id)).to.be.true;
205206
});
207+
208+
it("should delete user", async () => {
209+
await expectError(ErrorCodes.NOT_FOUND, userService.deleteUser(nonOrgUser.id, user2.id));
210+
await expectError(ErrorCodes.PERMISSION_DENIED, userService.deleteUser(user.id, user2.id));
211+
// user can delete themselves
212+
await userService.deleteUser(user.id, user.id);
213+
user = await userService.findUserById(user.id, user.id);
214+
expect(user.markedDeleted).to.be.true;
215+
216+
// org owners can delete users owned by org
217+
const orgOwner = await userService.createUser({
218+
organizationId: org.id,
219+
identity: {
220+
authId: "foo",
221+
authName: "bar",
222+
authProviderId: "github",
223+
primaryEmail: "[email protected]",
224+
},
225+
});
226+
await orgService.addOrUpdateMember(BUILTIN_INSTLLATION_ADMIN_USER_ID, org.id, orgOwner.id, "owner");
227+
228+
await expectError(ErrorCodes.NOT_FOUND, userService.deleteUser(orgOwner.id, nonOrgUser.id));
229+
await userService.deleteUser(orgOwner.id, user2.id);
230+
user2 = await userService.findUserById(orgOwner.id, user2.id);
231+
expect(user2.markedDeleted).to.be.true;
232+
233+
// admins can delete any user
234+
await userService.deleteUser(BUILTIN_INSTLLATION_ADMIN_USER_ID, nonOrgUser.id);
235+
nonOrgUser = await userService.findUserById(BUILTIN_INSTLLATION_ADMIN_USER_ID, nonOrgUser.id);
236+
expect(nonOrgUser.markedDeleted).to.be.true;
237+
});
206238
});

components/server/src/user/user-service.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { UserDB } from "@gitpod/gitpod-db/lib";
1010
import { Authorizer } from "../authorization/authorizer";
1111
import {
1212
AdditionalUserData,
13+
Disposable,
1314
Identity,
1415
RoleOrPermission,
1516
TokenEntry,
@@ -230,4 +231,75 @@ export class UserService {
230231

231232
await this.userDb.updateUserPartial({ id: userId, fgaRelationshipsVersion: undefined });
232233
}
234+
235+
private onDeleteListeners = new Set<
236+
(subjectId: string, user: User, transactionCtx: TransactionalContext) => Promise<void>
237+
>();
238+
public onDeleteUser(
239+
handler: (subjectId: string, user: User, transactionCtx: TransactionalContext) => Promise<void>,
240+
): Disposable {
241+
this.onDeleteListeners.add(handler);
242+
return {
243+
dispose: () => {
244+
this.onDeleteListeners.delete(handler);
245+
},
246+
};
247+
}
248+
249+
/**
250+
* This method deletes a User logically. The contract here is that after running this method without receiving an
251+
* error, the system does not contain any data that is relatable to the actual person in the sense of the GDPR.
252+
* To guarantee that, but also maintain traceability
253+
* we anonymize data that might contain user related/relatable data and keep the entities itself (incl. ids).
254+
*/
255+
async deleteUser(subjectId: string, targetUserId: string) {
256+
await this.authorizer.checkPermissionOnUser(subjectId, "delete", targetUserId);
257+
258+
await this.userDb.transaction(async (db, ctx) => {
259+
const user = await this.userDb.findUserById(targetUserId);
260+
if (!user) {
261+
throw new ApplicationError(ErrorCodes.NOT_FOUND, `No user with id ${targetUserId} found!`);
262+
}
263+
264+
if (user.markedDeleted === true) {
265+
log.debug({ userId: targetUserId }, "Is deleted but markDeleted already set. Continuing.");
266+
}
267+
for (const listener of this.onDeleteListeners) {
268+
await listener(subjectId, user, ctx);
269+
}
270+
user.avatarUrl = "deleted-avatarUrl";
271+
user.fullName = "deleted-fullName";
272+
user.name = "deleted-Name";
273+
if (user.verificationPhoneNumber) {
274+
user.verificationPhoneNumber = "deleted-phoneNumber";
275+
}
276+
for (const identity of user.identities) {
277+
identity.deleted = true;
278+
await db.deleteTokens(identity);
279+
}
280+
user.lastVerificationTime = undefined;
281+
user.markedDeleted = true;
282+
await db.storeUser(user);
283+
});
284+
285+
// Track the deletion Event for Analytics Purposes
286+
this.analytics.track({
287+
userId: targetUserId,
288+
event: "deletion",
289+
properties: {
290+
deleted_at: new Date().toISOString(),
291+
},
292+
});
293+
this.analytics.identify({
294+
userId: targetUserId,
295+
traits: {
296+
github_slug: "deleted-user",
297+
gitlab_slug: "deleted-user",
298+
bitbucket_slug: "deleted-user",
299+
email: "deleted-user",
300+
full_name: "deleted-user",
301+
name: "deleted-user",
302+
},
303+
});
304+
}
233305
}

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ import { NotFoundError, UnauthorizedError } from "../errors";
111111
import { RepoURL } from "../repohost/repo-url";
112112
import { AuthorizationService } from "../user/authorization-service";
113113
import { TokenProvider } from "../user/token-provider";
114-
import { UserDeletionService } from "../user/user-deletion-service";
115114
import { UserAuthentication } from "../user/user-authentication";
116115
import { ContextParser } from "./context-parser-service";
117116
import { GitTokenScopeGuesser } from "./git-token-scope-guesser";
@@ -214,7 +213,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
214213
@inject(TokenProvider) private readonly tokenProvider: TokenProvider,
215214
@inject(UserAuthentication) private readonly userAuthentication: UserAuthentication,
216215
@inject(UserService) private readonly userService: UserService,
217-
@inject(UserDeletionService) private readonly userDeletionService: UserDeletionService,
218216
@inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter,
219217
@inject(AuthorizationService) private readonly authorizationService: AuthorizationService,
220218
@inject(SSHKeyService) private readonly sshKeyservice: SSHKeyService,
@@ -710,7 +708,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
710708
const user = await this.checkAndBlockUser("deleteAccount");
711709
await this.guardAccess({ kind: "user", subject: user! }, "delete");
712710

713-
await this.userDeletionService.deleteUser(user.id, user.id);
711+
await this.userService.deleteUser(user.id, user.id);
714712
}
715713

716714
public async getWorkspace(ctx: TraceContext, workspaceId: string): Promise<WorkspaceInfo> {
@@ -2689,7 +2687,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
26892687

26902688
const admin = await this.guardAdminAccess("adminDeleteUser", { id: userId }, Permission.ADMIN_PERMISSIONS);
26912689

2692-
await this.userDeletionService.deleteUser(admin.id, userId);
2690+
await this.userService.deleteUser(admin.id, userId);
26932691
}
26942692

26952693
async adminVerifyUser(ctx: TraceContext, userId: string): Promise<User> {

components/spicedb/schema/schema.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ schema: |-
1313
// permissions
1414
permission read_info = self + organization->member + organization->owner + installation->admin
1515
permission write_info = self
16-
permission delete = self
16+
permission delete = self + organization->owner + installation->admin
1717
1818
permission make_admin = installation->admin + organization->installation_admin
1919

0 commit comments

Comments
 (0)