Skip to content

Commit ec7f6d6

Browse files
[fga] Introduce sshkeyservice (#18479)
* Introduce sshkeyservice * Add read_ssh and write_ssh permissions * add requestorId to sshkeyservice --------- Co-authored-by: svenefftinge <[email protected]>
1 parent 739ea72 commit ec7f6d6

File tree

6 files changed

+219
-43
lines changed

6 files changed

+219
-43
lines changed

components/server/src/authorization/definitions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export type UserResourceType = "user";
3232

3333
export type UserRelation = "self" | "organization" | "installation";
3434

35-
export type UserPermission = "read_info" | "write_info" | "make_admin";
35+
export type UserPermission = "read_info" | "write_info" | "make_admin" | "read_ssh" | "write_ssh";
3636

3737
export type InstallationResourceType = "installation";
3838

components/server/src/container-module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ import { RedisPublisher, newRedisClient } from "@gitpod/gitpod-db/lib";
129129
import { UserService } from "./user/user-service";
130130
import { RelationshipUpdater } from "./authorization/relationship-updater";
131131
import { WorkspaceService } from "./workspace/workspace-service";
132+
import { SSHKeyService } from "./user/sshkey-service";
132133

133134
export const productionContainerModule = new ContainerModule(
134135
(bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation) => {
@@ -143,6 +144,8 @@ export const productionContainerModule = new ContainerModule(
143144
bind(UserDeletionService).toSelf().inSingletonScope();
144145
bind(AuthorizationService).to(AuthorizationServiceImpl).inSingletonScope();
145146

147+
bind(SSHKeyService).toSelf().inSingletonScope();
148+
146149
bind(TokenService).toSelf().inSingletonScope();
147150
bind(TokenProvider).toService(TokenService);
148151

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { BUILTIN_INSTLLATION_ADMIN_USER_ID, TypeORM } from "@gitpod/gitpod-db/lib";
8+
import { Organization, SSHPublicKeyValue, User } from "@gitpod/gitpod-protocol";
9+
import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
10+
import * as chai from "chai";
11+
import { Container } from "inversify";
12+
import "mocha";
13+
import { createTestContainer } from "../test/service-testing-container-module";
14+
import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db";
15+
import { SSHKeyService } from "./sshkey-service";
16+
import { OrganizationService } from "../orgs/organization-service";
17+
import { UserService } from "./user-service";
18+
import { expectError } from "../test/expect-utils";
19+
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
20+
21+
const expect = chai.expect;
22+
23+
describe("SSHKeyService", async () => {
24+
let container: Container;
25+
let ss: SSHKeyService;
26+
27+
let member: User;
28+
let stranger: User;
29+
let org: Organization;
30+
31+
const testSSHkeys: SSHPublicKeyValue[] = [
32+
{
33+
name: "foo",
34+
key: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBN+Mh3U/3We4VYtV1QmWUFIzFLTUeegl1Ao5/QGtCRGAZn8bxX9KlCrrWISIjSYAwCajIEGSPEZwPNMBoK8XD8Q= test@gitpod",
35+
},
36+
{
37+
name: "bar",
38+
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0wmN/Cr3JXqmLW7u+g9pTh+wyqDHpSQEIQczXkVx9q bar@gitpod",
39+
},
40+
];
41+
42+
beforeEach(async () => {
43+
container = createTestContainer();
44+
Experiments.configureTestingClient({
45+
centralizedPermissions: true,
46+
});
47+
48+
const orgService = container.get<OrganizationService>(OrganizationService);
49+
org = await orgService.createOrganization(BUILTIN_INSTLLATION_ADMIN_USER_ID, "myOrg");
50+
const invite = await orgService.getOrCreateInvite(BUILTIN_INSTLLATION_ADMIN_USER_ID, org.id);
51+
52+
const userService = container.get<UserService>(UserService);
53+
member = await userService.createUser({
54+
organizationId: org.id,
55+
identity: {
56+
authId: "foo",
57+
authName: "bar",
58+
authProviderId: "github",
59+
primaryEmail: "[email protected]",
60+
},
61+
});
62+
await orgService.joinOrganization(member.id, invite.id);
63+
stranger = await userService.createUser({
64+
identity: {
65+
authId: "foo2",
66+
authName: "bar2",
67+
authProviderId: "github",
68+
},
69+
});
70+
71+
ss = container.get(SSHKeyService);
72+
});
73+
74+
afterEach(async () => {
75+
// Clean-up database
76+
await resetDB(container.get(TypeORM));
77+
});
78+
79+
it("should add ssh key", async () => {
80+
const resp1 = await ss.hasSSHPublicKey(member.id, member.id);
81+
expect(resp1).to.be.false;
82+
83+
await ss.addSSHPublicKey(member.id, member.id, testSSHkeys[0]);
84+
85+
const resp2 = await ss.hasSSHPublicKey(member.id, member.id);
86+
expect(resp2).to.be.true;
87+
88+
await expectError(ErrorCodes.NOT_FOUND, ss.hasSSHPublicKey(stranger.id, member.id));
89+
await expectError(ErrorCodes.NOT_FOUND, ss.addSSHPublicKey(stranger.id, member.id, testSSHkeys[0]));
90+
});
91+
92+
it("should list ssh keys", async () => {
93+
await ss.addSSHPublicKey(member.id, member.id, testSSHkeys[0]);
94+
await ss.addSSHPublicKey(member.id, member.id, testSSHkeys[1]);
95+
96+
const keys = await ss.getSSHPublicKeys(member.id, member.id);
97+
expect(keys.length).to.equal(2);
98+
expect(testSSHkeys.some((k) => k.name === keys[0].name && k.key === keys[0].key)).to.be.true;
99+
expect(testSSHkeys.some((k) => k.name === keys[1].name && k.key === keys[1].key)).to.be.true;
100+
101+
await expectError(ErrorCodes.NOT_FOUND, ss.getSSHPublicKeys(stranger.id, member.id));
102+
});
103+
104+
it("should delete ssh keys", async () => {
105+
await ss.addSSHPublicKey(member.id, member.id, testSSHkeys[0]);
106+
await ss.addSSHPublicKey(member.id, member.id, testSSHkeys[1]);
107+
108+
const keys = await ss.getSSHPublicKeys(member.id, member.id);
109+
expect(keys.length).to.equal(2);
110+
111+
await ss.deleteSSHPublicKey(member.id, member.id, keys[0].id);
112+
113+
const keys2 = await ss.getSSHPublicKeys(member.id, member.id);
114+
expect(keys2.length).to.equal(1);
115+
116+
await expectError(ErrorCodes.NOT_FOUND, ss.deleteSSHPublicKey(stranger.id, member.id, keys[0].id));
117+
});
118+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { UserDB, WorkspaceDB } from "@gitpod/gitpod-db/lib";
8+
import { SSHPublicKeyValue, UserSSHPublicKeyValue, WorkspaceInstance } from "@gitpod/gitpod-protocol";
9+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
10+
import { inject, injectable } from "inversify";
11+
import { UpdateSSHKeyRequest } from "@gitpod/ws-manager/lib";
12+
import { WorkspaceManagerClientProvider } from "@gitpod/ws-manager/lib/client-provider";
13+
import { Authorizer } from "../authorization/authorizer";
14+
15+
@injectable()
16+
export class SSHKeyService {
17+
constructor(
18+
@inject(WorkspaceDB) private readonly workspaceDb: WorkspaceDB,
19+
@inject(UserDB) private readonly userDB: UserDB,
20+
@inject(WorkspaceManagerClientProvider)
21+
private readonly workspaceManagerClientProvider: WorkspaceManagerClientProvider,
22+
@inject(Authorizer) private readonly auth: Authorizer,
23+
) {}
24+
25+
async hasSSHPublicKey(requestorId: string, userId: string): Promise<boolean> {
26+
await this.auth.checkPermissionOnUser(requestorId, "read_ssh", userId);
27+
return this.userDB.hasSSHPublicKey(userId);
28+
}
29+
30+
async getSSHPublicKeys(requestorId: string, userId: string): Promise<UserSSHPublicKeyValue[]> {
31+
await this.auth.checkPermissionOnUser(requestorId, "read_ssh", userId);
32+
const list = await this.userDB.getSSHPublicKeys(userId);
33+
return list.map((e) => ({
34+
id: e.id,
35+
name: e.name,
36+
key: e.key,
37+
fingerprint: e.fingerprint,
38+
creationTime: e.creationTime,
39+
lastUsedTime: e.lastUsedTime,
40+
}));
41+
}
42+
43+
async addSSHPublicKey(
44+
requestorId: string,
45+
userId: string,
46+
value: SSHPublicKeyValue,
47+
): Promise<UserSSHPublicKeyValue> {
48+
await this.auth.checkPermissionOnUser(requestorId, "write_ssh", userId);
49+
const data = await this.userDB.addSSHPublicKey(userId, value);
50+
this.updateSSHKeysForRegularRunningInstances(userId).catch((err) => {
51+
log.error("Failed to update ssh keys on running instances.", err);
52+
});
53+
return {
54+
id: data.id,
55+
name: data.name,
56+
key: data.key,
57+
fingerprint: data.fingerprint,
58+
creationTime: data.creationTime,
59+
lastUsedTime: data.lastUsedTime,
60+
};
61+
}
62+
63+
async deleteSSHPublicKey(requestorId: string, userId: string, id: string): Promise<void> {
64+
await this.auth.checkPermissionOnUser(requestorId, "write_ssh", userId);
65+
await this.userDB.deleteSSHPublicKey(userId, id);
66+
this.updateSSHKeysForRegularRunningInstances(userId).catch((err) => {
67+
log.error("Failed to update ssh keys on running instances.", err);
68+
});
69+
}
70+
71+
private async updateSSHKeysForRegularRunningInstances(userId: string) {
72+
const updateSSHKeysOfInstance = async (instance: WorkspaceInstance, sshKeys: string[]) => {
73+
try {
74+
const req = new UpdateSSHKeyRequest();
75+
req.setId(instance.id);
76+
req.setKeysList(sshKeys);
77+
const cli = await this.workspaceManagerClientProvider.get(instance.region);
78+
await cli.updateSSHPublicKey({}, req);
79+
} catch (err) {
80+
const logCtx = { userId, instanceId: instance.id };
81+
log.error(logCtx, "Could not update ssh public key for instance", err);
82+
}
83+
};
84+
const sshKeys = (await this.userDB.getSSHPublicKeys(userId)).map((e) => e.key);
85+
const instances = await this.workspaceDb.findRegularRunningInstances(userId);
86+
return Promise.allSettled(instances.map((instance) => updateSSHKeysOfInstance(instance, sshKeys)));
87+
}
88+
}

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

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ import {
111111
PortProtocol as ProtoPortProtocol,
112112
StopWorkspacePolicy,
113113
TakeSnapshotRequest,
114-
UpdateSSHKeyRequest,
115114
} from "@gitpod/ws-manager/lib/core_pb";
116115
import * as crypto from "crypto";
117116
import { inject, injectable } from "inversify";
@@ -194,6 +193,7 @@ import { RedisSubscriber } from "../messaging/redis-subscriber";
194193
import { UsageService } from "../orgs/usage-service";
195194
import { UserService } from "../user/user-service";
196195
import { WorkspaceService } from "./workspace-service";
196+
import { SSHKeyService } from "../user/sshkey-service";
197197

198198
// shortcut
199199
export const traceWI = (ctx: TraceContext, wi: Omit<LogContext, "userId">) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager
@@ -236,6 +236,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
236236
@inject(UserDeletionService) private readonly userDeletionService: UserDeletionService,
237237
@inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter,
238238
@inject(AuthorizationService) private readonly authorizationService: AuthorizationService,
239+
@inject(SSHKeyService) private readonly sshKeyservice: SSHKeyService,
239240

240241
@inject(TeamDB) private readonly teamDB: TeamDB,
241242
@inject(OrganizationService) private readonly organizationService: OrganizationService,
@@ -2445,59 +2446,22 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
24452446

24462447
async hasSSHPublicKey(ctx: TraceContext): Promise<boolean> {
24472448
const user = await this.checkUser("hasSSHPublicKey");
2448-
return this.userDB.hasSSHPublicKey(user.id);
2449+
return this.sshKeyservice.hasSSHPublicKey(user.id, user.id);
24492450
}
24502451

24512452
async getSSHPublicKeys(ctx: TraceContext): Promise<UserSSHPublicKeyValue[]> {
24522453
const user = await this.checkUser("getSSHPublicKeys");
2453-
const list = await this.userDB.getSSHPublicKeys(user.id);
2454-
return list.map((e) => ({
2455-
id: e.id,
2456-
name: e.name,
2457-
key: e.key,
2458-
fingerprint: e.fingerprint,
2459-
creationTime: e.creationTime,
2460-
lastUsedTime: e.lastUsedTime,
2461-
}));
2454+
return this.sshKeyservice.getSSHPublicKeys(user.id, user.id);
24622455
}
24632456

24642457
async addSSHPublicKey(ctx: TraceContext, value: SSHPublicKeyValue): Promise<UserSSHPublicKeyValue> {
24652458
const user = await this.checkUser("addSSHPublicKey");
2466-
const data = await this.userDB.addSSHPublicKey(user.id, value);
2467-
this.updateSSHKeysForRegularRunningInstances(ctx, user.id).catch(console.error);
2468-
return {
2469-
id: data.id,
2470-
name: data.name,
2471-
key: data.key,
2472-
fingerprint: data.fingerprint,
2473-
creationTime: data.creationTime,
2474-
lastUsedTime: data.lastUsedTime,
2475-
};
2459+
return this.sshKeyservice.addSSHPublicKey(user.id, user.id, value);
24762460
}
24772461

24782462
async deleteSSHPublicKey(ctx: TraceContext, id: string): Promise<void> {
24792463
const user = await this.checkUser("deleteSSHPublicKey");
2480-
await this.userDB.deleteSSHPublicKey(user.id, id);
2481-
this.updateSSHKeysForRegularRunningInstances(ctx, user.id).catch(console.error);
2482-
return;
2483-
}
2484-
2485-
private async updateSSHKeysForRegularRunningInstances(ctx: TraceContext, userId: string) {
2486-
const keys = (await this.userDB.getSSHPublicKeys(userId)).map((e) => e.key);
2487-
const instances = await this.workspaceDb.trace(ctx).findRegularRunningInstances(userId);
2488-
const updateKeyOfInstance = async (instance: WorkspaceInstance) => {
2489-
try {
2490-
const req = new UpdateSSHKeyRequest();
2491-
req.setId(instance.id);
2492-
req.setKeysList(keys);
2493-
const cli = await this.workspaceManagerClientProvider.get(instance.region);
2494-
await cli.updateSSHPublicKey(ctx, req);
2495-
} catch (err) {
2496-
const logCtx = { userId, instanceId: instance.id };
2497-
log.error(logCtx, "Could not update ssh public key for instance", err);
2498-
}
2499-
};
2500-
return Promise.allSettled(instances.map((e) => updateKeyOfInstance(e)));
2464+
return this.sshKeyservice.deleteSSHPublicKey(user.id, user.id, id);
25012465
}
25022466

25032467
async setProjectEnvironmentVariable(

components/spicedb/schema/schema.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ schema: |-
1414
permission read_info = self + organization->member + organization->owner + installation->admin
1515
permission write_info = self
1616
permission make_admin = installation->admin + organization->installation_admin
17+
18+
permission read_ssh = self
19+
permission write_ssh = self
1720
}
1821
1922
// There's only one global installation

0 commit comments

Comments
 (0)