Skip to content

[fga] Introduce sshkeyservice #18479

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

Merged
merged 3 commits into from
Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions components/server/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import { RedisPublisher, newRedisClient } from "@gitpod/gitpod-db/lib";
import { UserService } from "./user/user-service";
import { RelationshipUpdater } from "./authorization/relationship-updater";
import { WorkspaceService } from "./workspace/workspace-service";
import { SSHKeyService } from "./user/sshkey-service";

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

bind(SSHKeyService).toSelf().inSingletonScope();

bind(TokenService).toSelf().inSingletonScope();
bind(TokenProvider).toService(TokenService);

Expand Down
100 changes: 100 additions & 0 deletions components/server/src/user/sshkey-service.spec.db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { BUILTIN_INSTLLATION_ADMIN_USER_ID, TypeORM, UserDB } from "@gitpod/gitpod-db/lib";
import { Organization, SSHPublicKeyValue, User } from "@gitpod/gitpod-protocol";
import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
import * as chai from "chai";
import { Container } from "inversify";
import "mocha";
import { createTestContainer } from "../test/service-testing-container-module";
import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db";
import { SSHKeyService } from "./sshkey-service";
import { OrganizationService } from "../orgs/organization-service";

const expect = chai.expect;

describe("SSHKeyService", async () => {
let container: Container;
let ss: SSHKeyService;

let owner: User;
let member: User;
let org: Organization;

const testSSHkeys: SSHPublicKeyValue[] = [
{
name: "foo",
key: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBN+Mh3U/3We4VYtV1QmWUFIzFLTUeegl1Ao5/QGtCRGAZn8bxX9KlCrrWISIjSYAwCajIEGSPEZwPNMBoK8XD8Q= test@gitpod",
},
{
name: "bar",
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0wmN/Cr3JXqmLW7u+g9pTh+wyqDHpSQEIQczXkVx9q bar@gitpod",
},
];

beforeEach(async () => {
container = createTestContainer();
Experiments.configureTestingClient({
centralizedPermissions: true,
});

const userDB = container.get<UserDB>(UserDB);
owner = await userDB.newUser();

const os = container.get(OrganizationService);
org = await os.createOrganization(owner.id, "myorg");

member = await userDB.newUser();
const invite = await os.getOrCreateInvite(owner.id, org.id);
await os.joinOrganization(member.id, invite.id);

const adminUser = await userDB.findUserById(BUILTIN_INSTLLATION_ADMIN_USER_ID)!;
if (!adminUser) {
throw new Error("admin user not found");
}

ss = container.get(SSHKeyService);
});

afterEach(async () => {
// Clean-up database
await resetDB(container.get(TypeORM));
});

it("should add ssh key", async () => {
const resp1 = await ss.hasSSHPublicKey(member.id);
expect(resp1).to.be.false;

await ss.addSSHPublicKey(member.id, testSSHkeys[0]);

const resp2 = await ss.hasSSHPublicKey(member.id);
expect(resp2).to.be.true;
});

it("should list ssh keys", async () => {
await ss.addSSHPublicKey(member.id, testSSHkeys[0]);
await ss.addSSHPublicKey(member.id, testSSHkeys[1]);

const keys = await ss.getSSHPublicKeys(member.id);
expect(keys.length).to.equal(2);
expect(testSSHkeys.some((k) => k.name === keys[0].name && k.key === keys[0].key)).to.be.true;
expect(testSSHkeys.some((k) => k.name === keys[1].name && k.key === keys[1].key)).to.be.true;
});

it("should delete ssh keys", async () => {
await ss.addSSHPublicKey(member.id, testSSHkeys[0]);
await ss.addSSHPublicKey(member.id, testSSHkeys[1]);

const keys = await ss.getSSHPublicKeys(member.id);
expect(keys.length).to.equal(2);

await ss.deleteSSHPublicKey(member.id, keys[0].id);

const keys2 = await ss.getSSHPublicKeys(member.id);
expect(keys2.length).to.equal(1);
});
});
82 changes: 82 additions & 0 deletions components/server/src/user/sshkey-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { DBWithTracing, TracedWorkspaceDB, UserDB, WorkspaceDB } from "@gitpod/gitpod-db/lib";
import { SSHPublicKeyValue, UserSSHPublicKeyValue, WorkspaceInstance } from "@gitpod/gitpod-protocol";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { inject, injectable } from "inversify";
import { UpdateSSHKeyRequest } from "@gitpod/ws-manager/lib";
import { WorkspaceManagerClientProvider } from "@gitpod/ws-manager/lib/client-provider";

@injectable()
export class SSHKeyService {
constructor(
@inject(TracedWorkspaceDB) private readonly workspaceDb: DBWithTracing<WorkspaceDB>,
@inject(UserDB) private readonly userDB: UserDB,
@inject(WorkspaceManagerClientProvider)
private readonly workspaceManagerClientProvider: WorkspaceManagerClientProvider,
) {}

async hasSSHPublicKey(userId: string): Promise<boolean> {
return this.userDB.hasSSHPublicKey(userId);
}

async getSSHPublicKeys(userId: string): Promise<UserSSHPublicKeyValue[]> {
const list = await this.userDB.getSSHPublicKeys(userId);
return list.map((e) => ({
id: e.id,
name: e.name,
key: e.key,
fingerprint: e.fingerprint,
creationTime: e.creationTime,
lastUsedTime: e.lastUsedTime,
}));
}

async addSSHPublicKey(userId: string, value: SSHPublicKeyValue): Promise<UserSSHPublicKeyValue> {
const data = await this.userDB.addSSHPublicKey(userId, value);
this.updateSSHKeysForRegularRunningInstances(userId).catch(() => {
/* noop */
});
return {
id: data.id,
name: data.name,
key: data.key,
fingerprint: data.fingerprint,
creationTime: data.creationTime,
lastUsedTime: data.lastUsedTime,
};
}

async deleteSSHPublicKey(userId: string, id: string): Promise<void> {
await this.userDB.deleteSSHPublicKey(userId, id);
this.updateSSHKeysForRegularRunningInstances(userId).catch(() => {
/* noop */
});
}

private async updateSSHKeysForRegularRunningInstances(userId: string) {
const updateSSHKeysOfInstance = async (instance: WorkspaceInstance, sshKeys: string[]) => {
try {
const req = new UpdateSSHKeyRequest();
req.setId(instance.id);
req.setKeysList(sshKeys);
const cli = await this.workspaceManagerClientProvider.get(instance.region);
await cli.updateSSHPublicKey({}, req);
} catch (err) {
const logCtx = { userId, instanceId: instance.id };
log.error(logCtx, "Could not update ssh public key for instance", err);
}
};
try {
const sshKeys = (await this.userDB.getSSHPublicKeys(userId)).map((e) => e.key);
const instances = await this.workspaceDb.trace({}).findRegularRunningInstances(userId);
return Promise.allSettled(instances.map((instance) => updateSSHKeysOfInstance(instance, sshKeys)));
} catch (err) {
log.error("Failed to update ssh keys on running instances.", err);
}
}
}
48 changes: 6 additions & 42 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ import {
PortProtocol as ProtoPortProtocol,
StopWorkspacePolicy,
TakeSnapshotRequest,
UpdateSSHKeyRequest,
} from "@gitpod/ws-manager/lib/core_pb";
import * as crypto from "crypto";
import { inject, injectable } from "inversify";
Expand Down Expand Up @@ -194,6 +193,7 @@ import { RedisSubscriber } from "../messaging/redis-subscriber";
import { UsageService } from "../orgs/usage-service";
import { UserService } from "../user/user-service";
import { WorkspaceService } from "./workspace-service";
import { SSHKeyService } from "../user/sshkey-service";

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

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

async hasSSHPublicKey(ctx: TraceContext): Promise<boolean> {
const user = await this.checkUser("hasSSHPublicKey");
return this.userDB.hasSSHPublicKey(user.id);
return this.sshKeyservice.hasSSHPublicKey(user.id);
}

async getSSHPublicKeys(ctx: TraceContext): Promise<UserSSHPublicKeyValue[]> {
const user = await this.checkUser("getSSHPublicKeys");
const list = await this.userDB.getSSHPublicKeys(user.id);
return list.map((e) => ({
id: e.id,
name: e.name,
key: e.key,
fingerprint: e.fingerprint,
creationTime: e.creationTime,
lastUsedTime: e.lastUsedTime,
}));
return this.sshKeyservice.getSSHPublicKeys(user.id);
}

async addSSHPublicKey(ctx: TraceContext, value: SSHPublicKeyValue): Promise<UserSSHPublicKeyValue> {
const user = await this.checkUser("addSSHPublicKey");
const data = await this.userDB.addSSHPublicKey(user.id, value);
this.updateSSHKeysForRegularRunningInstances(ctx, user.id).catch(console.error);
return {
id: data.id,
name: data.name,
key: data.key,
fingerprint: data.fingerprint,
creationTime: data.creationTime,
lastUsedTime: data.lastUsedTime,
};
return this.sshKeyservice.addSSHPublicKey(user.id, value);
}

async deleteSSHPublicKey(ctx: TraceContext, id: string): Promise<void> {
const user = await this.checkUser("deleteSSHPublicKey");
await this.userDB.deleteSSHPublicKey(user.id, id);
this.updateSSHKeysForRegularRunningInstances(ctx, user.id).catch(console.error);
return;
}

private async updateSSHKeysForRegularRunningInstances(ctx: TraceContext, userId: string) {
const keys = (await this.userDB.getSSHPublicKeys(userId)).map((e) => e.key);
const instances = await this.workspaceDb.trace(ctx).findRegularRunningInstances(userId);
const updateKeyOfInstance = async (instance: WorkspaceInstance) => {
try {
const req = new UpdateSSHKeyRequest();
req.setId(instance.id);
req.setKeysList(keys);
const cli = await this.workspaceManagerClientProvider.get(instance.region);
await cli.updateSSHPublicKey(ctx, req);
} catch (err) {
const logCtx = { userId, instanceId: instance.id };
log.error(logCtx, "Could not update ssh public key for instance", err);
}
};
return Promise.allSettled(instances.map((e) => updateKeyOfInstance(e)));
return this.sshKeyservice.deleteSSHPublicKey(user.id, id);
}

async setProjectEnvironmentVariable(
Expand Down