Skip to content

Commit 8a8b148

Browse files
authored
[server] make first user owner (#18417)
1 parent a65bf95 commit 8a8b148

File tree

9 files changed

+138
-134
lines changed

9 files changed

+138
-134
lines changed

components/gitpod-db/src/typeorm/team-db-impl.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,12 +291,13 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
291291
const membershipRepo = await this.getMembershipRepo();
292292

293293
if (role != "owner") {
294-
const ownerCount = await membershipRepo.count({
294+
const allOwners = await membershipRepo.find({
295295
teamId,
296296
role: "owner",
297297
deleted: false,
298298
});
299-
if (ownerCount <= 1) {
299+
const otherOwnerCount = allOwners.filter((m) => m.userId != userId).length;
300+
if (otherOwnerCount === 0) {
300301
throw new ApplicationError(ErrorCodes.CONFLICT, "An organization must retain at least one owner");
301302
}
302303
}

components/server/BUILD.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ packages:
2424
yarnLock: ${coreYarnLockBase}/yarn.lock
2525
tsconfig: tsconfig.json
2626
commands:
27-
# leeway executes the build and test step in the wrong order, so we have to switch them here
28-
test: ["yarn", "build"]
29-
build: ["yarn", "test"]
27+
# leeway executes the build and test step in the wrong order, so we need to call a special script that builds before testing
28+
test: ["yarn", "test:leeway"]
3029
- name: docker
3130
type: docker
3231
deps:

components/server/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@
77
"start": "node ./dist/main.js",
88
"start-inspect": "node --inspect=0.0.0.0:9229 ./dist/main.js",
99
"generate": "leeway run components/spicedb:generate-ts > src/authorization/definitions.ts && npx prettier --write src/authorization/definitions.ts",
10-
"build": "yarn generate && yarn lint && npx tsc",
10+
"build": "yarn clean && yarn generate && yarn lint && npx tsc",
1111
"lint": "yarn eslint src/*.ts src/**/*.ts",
1212
"lint:fix": "yarn eslint src/*.ts src/**/*.ts --fix",
13-
"build:clean": "yarn clean && yarn build",
1413
"rebuild": "yarn build:clean",
1514
"build:watch": "watch 'yarn build' .",
1615
"watch": "leeway exec --package .:app --transitive-dependencies --filter-type yarn --components --parallel -- yarn build -w --preserveWatchOutput",
1716
"clean": "rimraf dist",
1817
"clean:node": "rimraf node_modules",
1918
"purge": "yarn clean && yarn clean:node && yarn run rimraf yarn.lock",
19+
"test:leeway": "yarn build && yarn test",
2020
"test": "cleanup() { echo 'Cleanup started'; yarn stop-services; }; trap cleanup EXIT; yarn test:unit && yarn start-services && yarn test:db",
2121
"test:unit": "mocha --opts mocha.opts './**/*.spec.js' --exclude './node_modules/**'",
2222
"test:db": ". $(leeway run components/gitpod-db:db-test-env) && mocha --opts mocha.opts './**/*.spec.db.js' --exclude './node_modules/**'",

components/server/src/iam/iam-session-app.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { OIDCCreateSessionPayload } from "./iam-oidc-create-session-payload";
2121
import { TeamMemberInfo, TeamMemberRole, User } from "@gitpod/gitpod-protocol";
2222
import { OrganizationService } from "../orgs/organization-service";
2323
import { UserService } from "../user/user-service";
24+
import { UserDB } from "@gitpod/gitpod-db/lib";
2425
const expect = chai.expect;
2526

2627
@suite(timeout(10000))
@@ -113,6 +114,11 @@ class TestIamSessionApp {
113114
bind(UserAuthentication).toConstantValue(this.userAuthenticationMock as any);
114115
bind(UserService).toConstantValue(this.userServiceMock as any);
115116
bind(OrganizationService).toConstantValue(this.orgServiceMock as any);
117+
bind(UserDB).toConstantValue(<any>{
118+
transaction: async (run: () => void) => {
119+
return run();
120+
},
121+
}); // unused
116122
}),
117123
);
118124
this.app = container.get(IamSessionApp);

components/server/src/iam/iam-session-app.ts

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { reportJWTCookieIssued } from "../prometheus-metrics";
1616
import { ApplicationError } from "@gitpod/gitpod-protocol/lib/messaging/error";
1717
import { OrganizationService } from "../orgs/organization-service";
1818
import { UserService } from "../user/user-service";
19+
import { UserDB } from "@gitpod/gitpod-db/lib";
1920

2021
@injectable()
2122
export class IamSessionApp {
@@ -26,6 +27,7 @@ export class IamSessionApp {
2627
@inject(UserService) private readonly userService: UserService,
2728
@inject(OrganizationService) private readonly orgService: OrganizationService,
2829
@inject(SessionHandler) private readonly session: SessionHandler,
30+
@inject(UserDB) private readonly userDb: UserDB,
2931
) {}
3032

3133
public getMiddlewares() {
@@ -108,22 +110,6 @@ export class IamSessionApp {
108110
}
109111
}
110112

111-
if (existingUser?.organizationId) {
112-
const members = await this.orgService.listMembers(existingUser.id, existingUser.organizationId);
113-
if (!members.some((m) => m.userId === existingUser?.id)) {
114-
// In case `createNewOIDCUser` failed to create a membership for this user,
115-
// let's try to fix the situation on the fly.
116-
// Also, if that step repeatedly fails, it would fail the login process earlier but
117-
// in a more consistent state.
118-
await this.orgService.addOrUpdateMember(
119-
undefined,
120-
existingUser.organizationId,
121-
existingUser.id,
122-
"member",
123-
);
124-
}
125-
}
126-
127113
return existingUser;
128114
}
129115

@@ -147,17 +133,22 @@ export class IamSessionApp {
147133
private async createNewOIDCUser(payload: OIDCCreateSessionPayload): Promise<User> {
148134
const { claims, organizationId } = payload;
149135

150-
// Until we support SKIM (or any other means to sync accounts) we create new users here as a side-effect of the login
151-
const user = await this.userService.createUser({
152-
organizationId,
153-
identity: { ...this.mapOIDCProfileToIdentity(payload), lastSigninTime: new Date().toISOString() },
154-
userUpdate: (user) => {
155-
user.name = claims.name;
156-
user.avatarUrl = claims.picture;
157-
},
136+
return this.userDb.transaction(async (_, ctx) => {
137+
// Until we support SKIM (or any other means to sync accounts) we create new users here as a side-effect of the login
138+
const user = await this.userService.createUser(
139+
{
140+
organizationId,
141+
identity: { ...this.mapOIDCProfileToIdentity(payload), lastSigninTime: new Date().toISOString() },
142+
userUpdate: (user) => {
143+
user.name = claims.name;
144+
user.avatarUrl = claims.picture;
145+
},
146+
},
147+
ctx,
148+
);
149+
150+
await this.orgService.addOrUpdateMember(undefined, organizationId, user.id, "member", ctx);
151+
return user;
158152
});
159-
160-
await this.orgService.addOrUpdateMember(undefined, organizationId, user.id, "member");
161-
return user;
162153
}
163154
}

components/server/src/orgs/organization-service.spec.db.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ describe("OrganizationService", async () => {
120120
owner = previouslyMember;
121121

122122
// owner can downgrade themselves only if they are not the last owner
123-
await expectError(ErrorCodes.CONFLICT, os.addOrUpdateMember(owner.id, org.id, owner.id, "member"));
123+
await os.addOrUpdateMember(owner.id, org.id, owner.id, "member");
124+
// verify they are still an owner
125+
const members = await os.listMembers(owner.id, org.id);
126+
expect(members.some((m) => m.userId === owner.id && m.role === "owner")).to.be.true;
124127

125128
// owner can delete themselves only if they are not the last owner
126129
await expectError(ErrorCodes.CONFLICT, os.removeOrganizationMember(owner.id, org.id, owner.id));

components/server/src/orgs/organization-service.ts

Lines changed: 73 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
1818
import { inject, injectable } from "inversify";
1919
import { Authorizer } from "../authorization/authorizer";
2020
import { ProjectsService } from "../projects/projects-service";
21+
import { TransactionalContext } from "@gitpod/gitpod-db/lib/typeorm/transactional-db-impl";
2122

2223
@injectable()
2324
export class OrganizationService {
@@ -183,90 +184,103 @@ export class OrganizationService {
183184
orgId: string,
184185
memberId: string,
185186
role: OrgMemberRole,
187+
txCtx?: TransactionalContext,
186188
): Promise<void> {
187189
if (userId) {
188190
await this.auth.checkPermissionOnOrganization(userId, "write_members", orgId);
189191
}
190-
if (role !== "owner") {
191-
const members = await this.teamDB.findMembersByTeam(orgId);
192-
if (!members.some((m) => m.userId !== memberId && m.role === "owner")) {
193-
throw new ApplicationError(ErrorCodes.CONFLICT, "Cannot remove the last owner of an organization.");
194-
}
195-
}
196-
const members = await this.teamDB.findMembersByTeam(orgId);
197-
const firstMember = members.filter((m) => m.userId !== BUILTIN_INSTLLATION_ADMIN_USER_ID).length === 0;
198-
if (firstMember) {
199-
// first member (that is not an admin) is going to be an owner
200-
role = "owner";
201-
log.info({ userId: memberId }, "First member of organization, setting role to owner.");
202-
}
203-
192+
let members: OrgMemberInfo[] = [];
204193
try {
205-
await this.teamDB.transaction(async (db) => {
206-
await db.addMemberToTeam(memberId, orgId);
207-
await db.setTeamMemberRole(memberId, orgId, role);
194+
await this.teamDB.transaction(txCtx, async (teamDB, txCtx) => {
195+
members = await teamDB.findMembersByTeam(orgId);
196+
const hasOtherRegularOwners =
197+
members.filter(
198+
(m) =>
199+
m.userId !== BUILTIN_INSTLLATION_ADMIN_USER_ID && //
200+
m.userId !== memberId && //
201+
m.role === "owner",
202+
).length > 0;
203+
if (!hasOtherRegularOwners) {
204+
// first regular member is going to be an owner
205+
role = "owner";
206+
log.info({ userId: memberId }, "First member of organization, setting role to owner.");
207+
}
208+
209+
await teamDB.addMemberToTeam(memberId, orgId);
210+
await teamDB.setTeamMemberRole(memberId, orgId, role);
208211
await this.auth.addOrganizationRole(orgId, memberId, role);
212+
// we can remove the built-in installation admin if we have added an owner
213+
if (!hasOtherRegularOwners && members.some((m) => m.userId === BUILTIN_INSTLLATION_ADMIN_USER_ID)) {
214+
try {
215+
await this.removeOrganizationMember(memberId, orgId, BUILTIN_INSTLLATION_ADMIN_USER_ID, txCtx);
216+
} catch (error) {
217+
log.warn("Failed to remove built-in installation admin from organization.", error);
218+
}
219+
}
209220
});
210221
} catch (err) {
211-
//TODO simply removing the user is not necessarily the right thing to do here, as the user might have been a member before
212-
await this.auth.removeOrganizationRole(orgId, memberId, "member");
222+
await this.auth.removeOrganizationRole(
223+
orgId,
224+
memberId,
225+
members.find((m) => m.userId === memberId)?.role || "member",
226+
);
213227
throw err;
214228
}
215-
// we can remove the built-in installation admin now
216-
if (firstMember) {
217-
try {
218-
await this.removeOrganizationMember(memberId, orgId, BUILTIN_INSTLLATION_ADMIN_USER_ID);
219-
} catch (error) {
220-
log.warn("Failed to remove built-in installation admin from organization.", error);
221-
}
222-
}
223229
}
224230

225-
public async removeOrganizationMember(userId: string, orgId: string, memberId: string): Promise<void> {
231+
public async removeOrganizationMember(
232+
userId: string,
233+
orgId: string,
234+
memberId: string,
235+
txCtx?: TransactionalContext,
236+
): Promise<void> {
226237
// The user is leaving a team, if they are removing themselves from the team.
227238
if (userId === memberId) {
228239
await this.auth.checkPermissionOnOrganization(userId, "read_info", orgId);
229240
} else {
230241
await this.auth.checkPermissionOnOrganization(userId, "write_members", orgId);
231242
}
243+
let membership: OrgMemberInfo | undefined;
244+
try {
245+
await this.teamDB.transaction(txCtx, async (db) => {
246+
// Check for existing membership.
247+
const members = await db.findMembersByTeam(orgId);
248+
// cannot remove last owner
249+
if (!members.some((m) => m.userId !== memberId && m.role === "owner")) {
250+
throw new ApplicationError(ErrorCodes.CONFLICT, "Cannot remove the last owner of an organization.");
251+
}
232252

233-
// Check for existing membership.
234-
const members = await this.teamDB.findMembersByTeam(orgId);
235-
// cannot remove last owner
236-
if (!members.some((m) => m.userId !== memberId && m.role === "owner")) {
237-
throw new ApplicationError(ErrorCodes.CONFLICT, "Cannot remove the last owner of an organization.");
238-
}
239-
240-
const membership = members.find((m) => m.userId === memberId);
241-
if (!membership) {
242-
throw new ApplicationError(
243-
ErrorCodes.NOT_FOUND,
244-
`Could not find membership for user '${memberId}' in organization '${orgId}'`,
245-
);
246-
}
253+
membership = members.find((m) => m.userId === memberId);
254+
if (!membership) {
255+
throw new ApplicationError(
256+
ErrorCodes.NOT_FOUND,
257+
`Could not find membership for user '${memberId}' in organization '${orgId}'`,
258+
);
259+
}
247260

248-
// Check if user's account belongs to the Org.
249-
const userToBeRemoved = await this.userDB.findUserById(memberId);
250-
if (!userToBeRemoved) {
251-
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Could not find user '${memberId}'`);
252-
}
253-
// Only invited members can be removed from the Org, but organizational accounts cannot.
254-
if (userToBeRemoved.organizationId && orgId === userToBeRemoved.organizationId) {
255-
throw new ApplicationError(
256-
ErrorCodes.PERMISSION_DENIED,
257-
`User's account '${memberId}' belongs to the organization '${orgId}'`,
258-
);
259-
}
261+
// Check if user's account belongs to the Org.
262+
const userToBeRemoved = await this.userDB.findUserById(memberId);
263+
if (!userToBeRemoved) {
264+
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Could not find user '${memberId}'`);
265+
}
266+
// Only invited members can be removed from the Org, but organizational accounts cannot.
267+
if (userToBeRemoved.organizationId && orgId === userToBeRemoved.organizationId) {
268+
throw new ApplicationError(
269+
ErrorCodes.PERMISSION_DENIED,
270+
`User's account '${memberId}' belongs to the organization '${orgId}'`,
271+
);
272+
}
260273

261-
try {
262-
await this.teamDB.transaction(async (db) => {
263274
await db.removeMemberFromTeam(userToBeRemoved.id, orgId);
264275
await this.auth.removeOrganizationRole(orgId, memberId, "member");
265276
});
266277
} catch (err) {
267-
// Rollback to the original role the user had
268-
await this.auth.addOrganizationRole(orgId, memberId, membership.role);
269-
throw new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, err);
278+
if (membership) {
279+
// Rollback to the original role the user had
280+
await this.auth.addOrganizationRole(orgId, memberId, membership.role);
281+
}
282+
const code = ApplicationError.hasErrorCode(err) ? err.code : ErrorCodes.INTERNAL_SERVER_ERROR;
283+
throw new ApplicationError(code, err);
270284
}
271285
this.analytics.track({
272286
userId,

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,7 @@ export class UserController {
138138

139139
// The user has supplied a valid token, we need to sign them in.
140140
// Login this user (sets cookie as side-effect)
141-
const user = await this.userService.findUserById(
142-
BUILTIN_INSTLLATION_ADMIN_USER_ID,
143-
BUILTIN_INSTLLATION_ADMIN_USER_ID,
144-
);
141+
const user = await this.userDb.findUserById(BUILTIN_INSTLLATION_ADMIN_USER_ID);
145142
if (!user) {
146143
// We respond with NOT_AUTHENTICATED to prevent gleaning whether the user, or token are invalid.
147144
throw new ApplicationError(ErrorCodes.NOT_AUTHENTICATED, "Admin user not found");

0 commit comments

Comments
 (0)