From 9f72153c1560ec92e0ad0c6f4ce9a00bd81835f7 Mon Sep 17 00:00:00 2001 From: Igor Vinokur Date: Fri, 9 Jul 2021 17:32:46 +0300 Subject: [PATCH 1/2] fix: Adapt the Secrets plugin API to use kubernetes secrets Signed-off-by: Igor Vinokur --- che-theia-init-sources.yml | 1 + .../eclipse-che-theia-credentials/.gitignore | 10 ++ .../package.json | 55 +++++++++++ .../src/browser/che-credentials-service.ts | 56 +++++++++++ .../browser/credentials-frontend-module.ts | 26 +++++ .../src/common/credentials-protocol.ts | 25 +++++ .../src/node/che-credentials-server.ts | 97 +++++++++++++++++++ .../che-theia-credentials-backend-module.ts | 24 +++++ .../tests/no-op.spec.ts | 13 +++ .../tsconfig.json | 14 +++ .../assembly-compile.tsconfig.mst.json | 3 + 11 files changed, 324 insertions(+) create mode 100644 extensions/eclipse-che-theia-credentials/.gitignore create mode 100644 extensions/eclipse-che-theia-credentials/package.json create mode 100644 extensions/eclipse-che-theia-credentials/src/browser/che-credentials-service.ts create mode 100644 extensions/eclipse-che-theia-credentials/src/browser/credentials-frontend-module.ts create mode 100644 extensions/eclipse-che-theia-credentials/src/common/credentials-protocol.ts create mode 100644 extensions/eclipse-che-theia-credentials/src/node/che-credentials-server.ts create mode 100644 extensions/eclipse-che-theia-credentials/src/node/che-theia-credentials-backend-module.ts create mode 100644 extensions/eclipse-che-theia-credentials/tests/no-op.spec.ts create mode 100644 extensions/eclipse-che-theia-credentials/tsconfig.json diff --git a/che-theia-init-sources.yml b/che-theia-init-sources.yml index 703902687..381936f08 100644 --- a/che-theia-init-sources.yml +++ b/che-theia-init-sources.yml @@ -21,6 +21,7 @@ sources: - extensions/eclipse-che-theia-remote-api - extensions/eclipse-che-theia-remote-impl-che-server - extensions/eclipse-che-theia-remote-impl-k8s + - extensions/eclipse-che-theia-credentials plugins: - plugins/containers-plugin - plugins/ext-plugin diff --git a/extensions/eclipse-che-theia-credentials/.gitignore b/extensions/eclipse-che-theia-credentials/.gitignore new file mode 100644 index 000000000..7813a26ec --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/.gitignore @@ -0,0 +1,10 @@ +conf +node_modules +dist +coverage +yarn-error.log +.vscode +lib +*.tgz +*.log +.eslintcache diff --git a/extensions/eclipse-che-theia-credentials/package.json b/extensions/eclipse-che-theia-credentials/package.json new file mode 100644 index 000000000..2d6ca155b --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/package.json @@ -0,0 +1,55 @@ +{ + "name": "@eclipse-che/theia-credentials", + "keywords": [ + "theia-extension" + ], + "version": "0.0.1", + "description": "Eclipse Che - Theia credentials", + "dependencies": { + "@theia/core": "next", + "@kubernetes/client-node": "^0.12.1", + "@eclipse-che/theia-remote-impl-che-server": "0.0.1" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/credentials-frontend-module" + }, + { + "backend": "lib/node/che-theia-credentials-backend-module" + } + ], + "license": "EPL-2.0", + "files": [ + "lib", + "src", + "scripts", + "conf" + ], + "scripts": { + "prepare": "yarn clean && yarn build && yarn test", + "clean": "rimraf lib", + "format": "if-env SKIP_FORMAT=true && echo 'skip format check' || prettier --check '{src,tests}/**/*.ts' package.json", + "format:fix": "prettier --write '{src,tests}/**/*.ts' package.json", + "lint": "if-env SKIP_LINT=true && echo 'skip lint check' || eslint --cache=true --no-error-on-unmatched-pattern=true '{src,tests}/**/*.ts'", + "lint:fix": "eslint --fix --cache=true --no-error-on-unmatched-pattern=true \"{src,tests}/**/*.{ts,tsx}\"", + "compile": "tsc", + "build": "concurrently -n \"format,lint,compile\" -c \"red,green,blue\" \"yarn format\" \"yarn lint\" \"yarn compile\"", + "watch": "tsc -w", + "test": "if-env SKIP_TEST=true && echo 'skip test' || jest --forceExit" + }, + "jest": { + "clearMocks": true, + "collectCoverage": true, + "collectCoverageFrom": [ + "src/**/*.{ts,tsx}" + ], + "coverageDirectory": "coverage", + "modulePathIgnorePatterns": [ + "/lib" + ], + "preset": "ts-jest" + } +} diff --git a/extensions/eclipse-che-theia-credentials/src/browser/che-credentials-service.ts b/extensions/eclipse-che-theia-credentials/src/browser/che-credentials-service.ts new file mode 100644 index 000000000..f6c6513c3 --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/src/browser/che-credentials-service.ts @@ -0,0 +1,56 @@ +/********************************************************************** + * Copyright (c) 2019-2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +import { CredentialsChangeEvent, CredentialsService } from '@theia/core/lib/browser/credentials-service'; +import { Emitter, Event } from '@theia/core'; +import { inject, injectable } from 'inversify'; + +import { CredentialsServer } from '../common/credentials-protocol'; + +@injectable() +export class CheCredentialsService implements CredentialsService { + @inject(CredentialsServer) + private readonly credentialsServer: CredentialsServer; + + private readonly onDidChangePasswordEmitter = new Emitter(); + readonly onDidChangePassword: Event = this.onDidChangePasswordEmitter.event; + + async deletePassword(service: string, account: string): Promise { + const result = await this.credentialsServer.deletePassword(this.getExtensionId(service), account); + if (result) { + this.onDidChangePasswordEmitter.fire({ service, account }); + } + return result; + } + + findCredentials(service: string): Promise> { + return this.credentialsServer.findCredentials(this.getExtensionId(service)); + } + + findPassword(service: string): Promise { + return this.credentialsServer.findPassword(this.getExtensionId(service)); + } + + async getPassword(service: string, account: string): Promise { + const passwordContent = await this.credentialsServer.getPassword(this.getExtensionId(service), account); + if (passwordContent) { + return JSON.stringify(passwordContent); + } + } + + async setPassword(service: string, account: string, password: string): Promise { + await this.credentialsServer.setPassword(this.getExtensionId(service), account, JSON.parse(password)); + this.onDidChangePasswordEmitter.fire({ service, account }); + } + + private getExtensionId(service: string): string { + return service.replace(window.location.hostname + '-', ''); + } +} diff --git a/extensions/eclipse-che-theia-credentials/src/browser/credentials-frontend-module.ts b/extensions/eclipse-che-theia-credentials/src/browser/credentials-frontend-module.ts new file mode 100644 index 000000000..45dc030e6 --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/src/browser/credentials-frontend-module.ts @@ -0,0 +1,26 @@ +/********************************************************************** + * Copyright (c) 2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +import { CREDENTIALS_SERVICE_PATH, CredentialsServer } from '../common/credentials-protocol'; + +import { CheCredentialsService } from './che-credentials-service'; +import { ContainerModule } from 'inversify'; +import { CredentialsService } from '@theia/core/lib/browser/credentials-service'; +import { WebSocketConnectionProvider } from '@theia/core/lib/browser'; + +export default new ContainerModule((bind, unbind, isBound, rebind) => { + bind(CheCredentialsService).toSelf().inSingletonScope(); + rebind(CredentialsService).to(CheCredentialsService).inSingletonScope(); + bind(CredentialsServer) + .toDynamicValue(context => + context.container.get(WebSocketConnectionProvider).createProxy(CREDENTIALS_SERVICE_PATH) + ) + .inSingletonScope(); +}); diff --git a/extensions/eclipse-che-theia-credentials/src/common/credentials-protocol.ts b/extensions/eclipse-che-theia-credentials/src/common/credentials-protocol.ts new file mode 100644 index 000000000..8538991a3 --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/src/common/credentials-protocol.ts @@ -0,0 +1,25 @@ +/********************************************************************** + * Copyright (c) 2021 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +export const CREDENTIALS_SERVICE_PATH = '/services/credentials'; +export const CredentialsServer = Symbol('CredentialsServer'); + +export interface PasswordContent { + extensionId: string; + content: string; +} + +export interface CredentialsServer { + setPassword(service: string, account: string, passwordData: PasswordContent): Promise; + getPassword(service: string, account: string): Promise; + deletePassword(service: string, account: string): Promise; + findPassword(service: string): Promise; + findCredentials(service: string): Promise>; +} diff --git a/extensions/eclipse-che-theia-credentials/src/node/che-credentials-server.ts b/extensions/eclipse-che-theia-credentials/src/node/che-credentials-server.ts new file mode 100644 index 000000000..f96d557ed --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/src/node/che-credentials-server.ts @@ -0,0 +1,97 @@ +/********************************************************************** + * Copyright (c) 2021 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +import * as k8s from '@kubernetes/client-node'; + +import { CredentialsServer, PasswordContent } from '../common/credentials-protocol'; +import { inject, injectable } from 'inversify'; + +import { CheK8SServiceImpl } from '@eclipse-che/theia-remote-impl-che-server/lib/node/che-server-k8s-service-impl'; +import { CheServerWorkspaceServiceImpl } from '@eclipse-che/theia-remote-impl-che-server/lib/node/che-server-workspace-service-impl'; + +@injectable() +export class CheCredentialsServer implements CredentialsServer { + @inject(CheK8SServiceImpl) + private readonly cheK8SService: CheK8SServiceImpl; + + @inject(CheServerWorkspaceServiceImpl) + private readonly workspaceService: CheServerWorkspaceServiceImpl; + + private INFRASTRUCTURE_NAMESPACE = 'infrastructureNamespace'; + + async deletePassword(service: string, account: string): Promise { + try { + await this.cheK8SService + .makeApiClient(k8s.CoreV1Api) + .deleteNamespacedSecret(this.getSecretName(service), await this.getWorkspaceNamespace()); + return true; + } catch (e) { + console.error(e); + return false; + } + } + + async findCredentials(service: string): Promise> { + const secrets = await this.listNamespacedSecrets(); + return secrets + .filter(secret => secret.metadata && secret.metadata.name && secret.metadata.name === this.getSecretName(service)) + .map(secret => ({ + account: secret.metadata!.name!.substring(service.length + 1), + password: secret.data!.password, + })); + } + + async findPassword(service: string): Promise { + const secrets = await this.listNamespacedSecrets(); + const item = secrets.find( + secret => secret.metadata && secret.metadata.name && secret.metadata.name === this.getSecretName(service) + ); + if (item) { + return item.data!.password; + } + } + + async getPassword(service: string, account: string): Promise { + const secrets = await this.listNamespacedSecrets(); + const item = secrets.find( + secret => secret.metadata && secret.metadata.name && secret.metadata.name === this.getSecretName(service) + ); + if (item) { + return { extensionId: service, content: Buffer.from(item.data![account], 'base64').toString('ascii') }; + } + } + + async setPassword(service: string, account: string, password: PasswordContent): Promise { + const secret: k8s.V1Secret = { + metadata: { name: this.getSecretName(service) }, + data: { [account]: Buffer.from(password.content).toString('base64') }, + }; + await this.cheK8SService + .makeApiClient(k8s.CoreV1Api) + .createNamespacedSecret(await this.getWorkspaceNamespace(), secret); + } + + private getSecretName(service: string): string { + return service.substring(service.indexOf('/') + 1) + '-credentials'; + } + + private async listNamespacedSecrets(): Promise { + const secrets = await this.cheK8SService + .makeApiClient(k8s.CoreV1Api) + .listNamespacedSecret(await this.getWorkspaceNamespace()); + return secrets.body.items; + } + + private async getWorkspaceNamespace(): Promise { + // grab current workspace + const workspace = await this.workspaceService.currentWorkspace(); + return workspace.attributes?.[this.INFRASTRUCTURE_NAMESPACE] || workspace.namespace || ''; + } +} diff --git a/extensions/eclipse-che-theia-credentials/src/node/che-theia-credentials-backend-module.ts b/extensions/eclipse-che-theia-credentials/src/node/che-theia-credentials-backend-module.ts new file mode 100644 index 000000000..2b6e1a4e6 --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/src/node/che-theia-credentials-backend-module.ts @@ -0,0 +1,24 @@ +/********************************************************************** + * Copyright (c) 2019-2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +import { CREDENTIALS_SERVICE_PATH, CredentialsServer } from '../common/credentials-protocol'; +import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core'; + +import { CheCredentialsServer } from './che-credentials-server'; +import { ContainerModule } from 'inversify'; + +export default new ContainerModule(bind => { + bind(CredentialsServer).to(CheCredentialsServer).inSingletonScope(); + bind(ConnectionHandler) + .toDynamicValue( + context => new JsonRpcConnectionHandler(CREDENTIALS_SERVICE_PATH, () => context.container.get(CredentialsServer)) + ) + .inSingletonScope(); +}); diff --git a/extensions/eclipse-che-theia-credentials/tests/no-op.spec.ts b/extensions/eclipse-che-theia-credentials/tests/no-op.spec.ts new file mode 100644 index 000000000..3ec8409a6 --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/tests/no-op.spec.ts @@ -0,0 +1,13 @@ +/********************************************************************** + * Copyright (c) 2021 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +describe('no-op', function () { + it('no-op', function () {}); +}); diff --git a/extensions/eclipse-che-theia-credentials/tsconfig.json b/extensions/eclipse-che-theia-credentials/tsconfig.json new file mode 100644 index 000000000..a6a832536 --- /dev/null +++ b/extensions/eclipse-che-theia-credentials/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../configs/base.tsconfig.json", + "compilerOptions": { + "lib": [ + "es6", + "dom" + ], + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ] +} diff --git a/generator/src/templates/assembly-compile.tsconfig.mst.json b/generator/src/templates/assembly-compile.tsconfig.mst.json index cf94075e6..262018203 100644 --- a/generator/src/templates/assembly-compile.tsconfig.mst.json +++ b/generator/src/templates/assembly-compile.tsconfig.mst.json @@ -50,6 +50,9 @@ }, { "path": "{{ packageRefPrefix}}eclipse-che-theia-mini-browser/tsconfig.json" + }, + { + "path": "{{ packageRefPrefix}}eclipse-che-theia-credentials/tsconfig.json" } ] } From e7c5cecd0c1dd79c4153903b5a1382c3969b2c03 Mon Sep 17 00:00:00 2001 From: Igor Vinokur Date: Wed, 4 Aug 2021 17:32:21 +0300 Subject: [PATCH 2/2] Update to use single secret Signed-off-by: Igor Vinokur --- .../src/browser/che-credentials-service.ts | 2 +- .../browser/credentials-frontend-module.ts | 2 +- .../src/node/che-credentials-server.ts | 90 +++++++++++-------- .../che-theia-credentials-backend-module.ts | 2 +- 4 files changed, 55 insertions(+), 41 deletions(-) diff --git a/extensions/eclipse-che-theia-credentials/src/browser/che-credentials-service.ts b/extensions/eclipse-che-theia-credentials/src/browser/che-credentials-service.ts index f6c6513c3..44f890991 100644 --- a/extensions/eclipse-che-theia-credentials/src/browser/che-credentials-service.ts +++ b/extensions/eclipse-che-theia-credentials/src/browser/che-credentials-service.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2019-2020 Red Hat, Inc. + * Copyright (c) 2019-2021 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 diff --git a/extensions/eclipse-che-theia-credentials/src/browser/credentials-frontend-module.ts b/extensions/eclipse-che-theia-credentials/src/browser/credentials-frontend-module.ts index 45dc030e6..ecab14431 100644 --- a/extensions/eclipse-che-theia-credentials/src/browser/credentials-frontend-module.ts +++ b/extensions/eclipse-che-theia-credentials/src/browser/credentials-frontend-module.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2020 Red Hat, Inc. + * Copyright (c) 2021 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 diff --git a/extensions/eclipse-che-theia-credentials/src/node/che-credentials-server.ts b/extensions/eclipse-che-theia-credentials/src/node/che-credentials-server.ts index f96d557ed..b0aa4afba 100644 --- a/extensions/eclipse-che-theia-credentials/src/node/che-credentials-server.ts +++ b/extensions/eclipse-che-theia-credentials/src/node/che-credentials-server.ts @@ -24,13 +24,20 @@ export class CheCredentialsServer implements CredentialsServer { @inject(CheServerWorkspaceServiceImpl) private readonly workspaceService: CheServerWorkspaceServiceImpl; - private INFRASTRUCTURE_NAMESPACE = 'infrastructureNamespace'; + private readonly CREDENTIALS_SECRET_NAME = 'workspace-credentials-secret'; + private readonly INFRASTRUCTURE_NAMESPACE = 'infrastructureNamespace'; async deletePassword(service: string, account: string): Promise { try { - await this.cheK8SService - .makeApiClient(k8s.CoreV1Api) - .deleteNamespacedSecret(this.getSecretName(service), await this.getWorkspaceNamespace()); + const patch = [ + { + op: 'remove', + path: `/data/${this.getSecretDataItemName(service, account)}`, + }, + ]; + const client = this.cheK8SService.makeApiClient(k8s.CoreV1Api); + client.defaultHeaders = { Accept: 'application/json', 'Content-Type': k8s.PatchUtils.PATCH_FORMAT_JSON_PATCH }; + await client.patchNamespacedSecret(this.CREDENTIALS_SECRET_NAME, await this.getWorkspaceNamespace(), patch); return true; } catch (e) { console.error(e); @@ -39,54 +46,61 @@ export class CheCredentialsServer implements CredentialsServer { } async findCredentials(service: string): Promise> { - const secrets = await this.listNamespacedSecrets(); - return secrets - .filter(secret => secret.metadata && secret.metadata.name && secret.metadata.name === this.getSecretName(service)) - .map(secret => ({ - account: secret.metadata!.name!.substring(service.length + 1), - password: secret.data!.password, - })); + const secret = await this.cheK8SService + .makeApiClient(k8s.CoreV1Api) + .readNamespacedSecret(this.CREDENTIALS_SECRET_NAME, await this.getWorkspaceNamespace()); + const data = secret.body.data; + return data + ? Object.keys(data) + .filter(key => key.startsWith(service)) + .map(key => ({ + account: key.substring(key.indexOf('_') + 1), + password: Buffer.from(data[key], 'base64').toString('ascii'), + })) + : []; } async findPassword(service: string): Promise { - const secrets = await this.listNamespacedSecrets(); - const item = secrets.find( - secret => secret.metadata && secret.metadata.name && secret.metadata.name === this.getSecretName(service) - ); - if (item) { - return item.data!.password; + const secret = await this.cheK8SService + .makeApiClient(k8s.CoreV1Api) + .readNamespacedSecret(this.CREDENTIALS_SECRET_NAME, await this.getWorkspaceNamespace()); + const data = secret.body.data; + if (data) { + const result = Object.keys(data).find(key => key.startsWith(service)); + if (result) { + return Buffer.from(data[result], 'base64').toString('ascii'); + } } } async getPassword(service: string, account: string): Promise { - const secrets = await this.listNamespacedSecrets(); - const item = secrets.find( - secret => secret.metadata && secret.metadata.name && secret.metadata.name === this.getSecretName(service) - ); - if (item) { - return { extensionId: service, content: Buffer.from(item.data![account], 'base64').toString('ascii') }; + const secret = await this.cheK8SService + .makeApiClient(k8s.CoreV1Api) + .readNamespacedSecret(this.CREDENTIALS_SECRET_NAME, await this.getWorkspaceNamespace()); + const data = secret.body.data; + if (data && data[this.getSecretDataItemName(service, account)]) { + return { + extensionId: service, + content: Buffer.from(secret.body.data![this.getSecretDataItemName(service, account)], 'base64').toString( + 'ascii' + ), + }; } } async setPassword(service: string, account: string, password: PasswordContent): Promise { - const secret: k8s.V1Secret = { - metadata: { name: this.getSecretName(service) }, - data: { [account]: Buffer.from(password.content).toString('base64') }, + const client = this.cheK8SService.makeApiClient(k8s.CoreV1Api); + client.defaultHeaders = { + Accept: 'application/json', + 'Content-Type': k8s.PatchUtils.PATCH_FORMAT_STRATEGIC_MERGE_PATCH, }; - await this.cheK8SService - .makeApiClient(k8s.CoreV1Api) - .createNamespacedSecret(await this.getWorkspaceNamespace(), secret); + await client.patchNamespacedSecret(this.CREDENTIALS_SECRET_NAME, await this.getWorkspaceNamespace(), { + data: { [this.getSecretDataItemName(service, account)]: Buffer.from(password.content).toString('base64') }, + }); } - private getSecretName(service: string): string { - return service.substring(service.indexOf('/') + 1) + '-credentials'; - } - - private async listNamespacedSecrets(): Promise { - const secrets = await this.cheK8SService - .makeApiClient(k8s.CoreV1Api) - .listNamespacedSecret(await this.getWorkspaceNamespace()); - return secrets.body.items; + private getSecretDataItemName(service: string, account: string): string { + return `${service}_${account}`; } private async getWorkspaceNamespace(): Promise { diff --git a/extensions/eclipse-che-theia-credentials/src/node/che-theia-credentials-backend-module.ts b/extensions/eclipse-che-theia-credentials/src/node/che-theia-credentials-backend-module.ts index 2b6e1a4e6..217719308 100644 --- a/extensions/eclipse-che-theia-credentials/src/node/che-theia-credentials-backend-module.ts +++ b/extensions/eclipse-che-theia-credentials/src/node/che-theia-credentials-backend-module.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2019-2020 Red Hat, Inc. + * Copyright (c) 2019-2021 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0