diff --git a/.changeset/spicy-dragons-act.md b/.changeset/spicy-dragons-act.md new file mode 100644 index 0000000000..091df63554 --- /dev/null +++ b/.changeset/spicy-dragons-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Added support for Google Service account credentials config to use in GoogleServiceAccountStrategy diff --git a/plugins/kubernetes-backend/config.d.ts b/plugins/kubernetes-backend/config.d.ts index a710b8cb85..74b2f57a31 100644 --- a/plugins/kubernetes-backend/config.d.ts +++ b/plugins/kubernetes-backend/config.d.ts @@ -98,6 +98,13 @@ export interface Config { plural: string; }>; + /** + * (Optional) Google Service Account credentials for authentication + * JSON string containing the service account key + * @visibility secret + */ + googleServiceAccountCredentials?: string; + /** * (Optional) API Version Overrides * If set, the specified api version will be used to make requests for the corresponding object. diff --git a/plugins/kubernetes-backend/report.api.md b/plugins/kubernetes-backend/report.api.md index dfc93b9da6..798d5d7d29 100644 --- a/plugins/kubernetes-backend/report.api.md +++ b/plugins/kubernetes-backend/report.api.md @@ -90,8 +90,9 @@ export type DispatchStrategyOptions = { }; }; -// @public (undocumented) +// @public export class GoogleServiceAccountStrategy implements AuthenticationStrategy { + constructor(opts: { config: Config }); // (undocumented) getCredential(): Promise; // (undocumented) diff --git a/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.test.ts b/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.test.ts new file mode 100644 index 0000000000..34022556b8 --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { GoogleServiceAccountStrategy } from './GoogleServiceAccountStrategy'; + +// Mock the @google-cloud/container module +const mockGetAccessToken = jest.fn(); + +jest.mock('@google-cloud/container', () => { + const mockClusterManagerClient = jest.fn().mockImplementation(() => ({ + auth: { + getAccessToken: mockGetAccessToken, + }, + })); + + return { + v1: { + ClusterManagerClient: mockClusterManagerClient, + }, + }; +}); + +// Get reference to the mocked constructor for use in tests +const { + v1: { ClusterManagerClient: MockedClusterManagerClient }, +} = jest.mocked(require('@google-cloud/container')); + +describe('GoogleServiceAccountStrategy', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should read credentials from config when provided', () => { + const config = new ConfigReader({ + kubernetes: { + googleServiceAccountCredentials: '{"type": "service_account"}', + }, + }); + + const strategy = new GoogleServiceAccountStrategy({ config }); + expect(strategy).toBeDefined(); + }); + + it('should work without credentials in config', () => { + const config = new ConfigReader({ + kubernetes: {}, + }); + + const strategy = new GoogleServiceAccountStrategy({ config }); + expect(strategy).toBeDefined(); + }); + + it('should work with empty config', () => { + const config = new ConfigReader({}); + + const strategy = new GoogleServiceAccountStrategy({ config }); + expect(strategy).toBeDefined(); + }); + }); + + describe('#getCredential', () => { + it('should use credentials from config when provided', async () => { + const serviceAccountKey = { + type: 'service_account', + project_id: 'test-project', + private_key_id: 'key-id', + private_key: + '-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----\n', + client_email: 'test@test-project.iam.gserviceaccount.com', + client_id: '123456789', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + }; + + const config = new ConfigReader({ + kubernetes: { + googleServiceAccountCredentials: JSON.stringify(serviceAccountKey), + }, + }); + + mockGetAccessToken.mockResolvedValue('test-access-token'); + + const strategy = new GoogleServiceAccountStrategy({ config }); + const credential = await strategy.getCredential(); + + expect(MockedClusterManagerClient).toHaveBeenCalledWith({ + credentials: serviceAccountKey, + }); + expect(mockGetAccessToken).toHaveBeenCalled(); + expect(credential).toEqual({ + type: 'bearer token', + token: 'test-access-token', + }); + }); + + it('should fall back to default credentials when no config provided', async () => { + const config = new ConfigReader({ + kubernetes: {}, + }); + + mockGetAccessToken.mockResolvedValue('default-access-token'); + + const strategy = new GoogleServiceAccountStrategy({ config }); + const credential = await strategy.getCredential(); + + expect(MockedClusterManagerClient).toHaveBeenCalledWith(); + expect(mockGetAccessToken).toHaveBeenCalled(); + expect(credential).toEqual({ + type: 'bearer token', + token: 'default-access-token', + }); + }); + + it('should throw error when JSON parsing fails', async () => { + const config = new ConfigReader({ + kubernetes: { + googleServiceAccountCredentials: 'invalid-json', + }, + }); + + const strategy = new GoogleServiceAccountStrategy({ config }); + + await expect(strategy.getCredential()).rejects.toThrow( + 'Failed to parse Google Service Account credentials from config', + ); + + expect(MockedClusterManagerClient).not.toHaveBeenCalled(); + expect(mockGetAccessToken).not.toHaveBeenCalled(); + }); + + it('should throw error when access token is null', async () => { + const config = new ConfigReader({ + kubernetes: {}, + }); + + mockGetAccessToken.mockResolvedValue(null); + + const strategy = new GoogleServiceAccountStrategy({ config }); + + await expect(strategy.getCredential()).rejects.toThrow( + 'Unable to obtain access token for Google Cloud authentication', + ); + + expect(MockedClusterManagerClient).toHaveBeenCalledWith(); + expect(mockGetAccessToken).toHaveBeenCalled(); + }); + + it('should throw error when access token is undefined', async () => { + const config = new ConfigReader({ + kubernetes: {}, + }); + + mockGetAccessToken.mockResolvedValue(undefined); + + const strategy = new GoogleServiceAccountStrategy({ config }); + + await expect(strategy.getCredential()).rejects.toThrow( + 'Unable to obtain access token for Google Cloud authentication', + ); + + expect(MockedClusterManagerClient).toHaveBeenCalledWith(); + expect(mockGetAccessToken).toHaveBeenCalled(); + }); + + it('should handle empty string access token', async () => { + const config = new ConfigReader({ + kubernetes: {}, + }); + + mockGetAccessToken.mockResolvedValue(''); + + const strategy = new GoogleServiceAccountStrategy({ config }); + + await expect(strategy.getCredential()).rejects.toThrow( + 'Unable to obtain access token for Google Cloud authentication', + ); + + expect(MockedClusterManagerClient).toHaveBeenCalledWith(); + expect(mockGetAccessToken).toHaveBeenCalled(); + }); + + it('should handle malformed JSON with specific error message', async () => { + const config = new ConfigReader({ + kubernetes: { + googleServiceAccountCredentials: '{"invalid": json}', + }, + }); + + const strategy = new GoogleServiceAccountStrategy({ config }); + + await expect(strategy.getCredential()).rejects.toThrow( + /Failed to parse Google Service Account credentials from config: Unexpected token/, + ); + }); + + it('should handle client creation errors', async () => { + const serviceAccountKey = { + type: 'service_account', + project_id: 'test-project', + }; + + const config = new ConfigReader({ + kubernetes: { + googleServiceAccountCredentials: JSON.stringify(serviceAccountKey), + }, + }); + + MockedClusterManagerClient.mockImplementationOnce(() => { + throw new Error('Client creation failed'); + }); + + const strategy = new GoogleServiceAccountStrategy({ config }); + + await expect(strategy.getCredential()).rejects.toThrow( + 'Client creation failed', + ); + }); + + it('should handle getAccessToken errors', async () => { + const config = new ConfigReader({ + kubernetes: {}, + }); + + mockGetAccessToken.mockRejectedValue(new Error('Token fetch failed')); + + const strategy = new GoogleServiceAccountStrategy({ config }); + + await expect(strategy.getCredential()).rejects.toThrow( + 'Token fetch failed', + ); + + expect(MockedClusterManagerClient).toHaveBeenCalledWith(); + expect(mockGetAccessToken).toHaveBeenCalled(); + }); + }); + + describe('#validateCluster', () => { + it('should return empty array', () => { + const config = new ConfigReader({}); + const strategy = new GoogleServiceAccountStrategy({ config }); + + const result = strategy.validateCluster(); + + expect(result).toEqual([]); + }); + }); + + describe('#presentAuthMetadata', () => { + it('should return empty object', () => { + const config = new ConfigReader({}); + const strategy = new GoogleServiceAccountStrategy({ config }); + + const result = strategy.presentAuthMetadata({ test: 'metadata' }); + + expect(result).toEqual({}); + }); + }); +}); diff --git a/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.ts b/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.ts index f6757d6a64..6bb7f8d7ee 100644 --- a/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.ts +++ b/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.ts @@ -20,19 +20,69 @@ import { KubernetesCredential, } from '@backstage/plugin-kubernetes-node'; import * as container from '@google-cloud/container'; +import { Config } from '@backstage/config'; /** + * GoogleServiceAccountStrategy provides authentication using Google Service Account credentials. + * + * Credentials can be provided via configuration: + * ```yaml + * kubernetes: + * googleServiceAccountCredentials: | + * { + * "type": "service_account", + * "project_id": "your-project-id", + * "private_key_id": "key-id", + * "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", + * "client_email": "your-service-account@your-project.iam.gserviceaccount.com", + * "client_id": "client-id", + * "auth_uri": "https://accounts.google.com/o/oauth2/auth", + * "token_uri": "https://oauth2.googleapis.com/token", + * "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + * "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/..." + * } + * ``` + * + * If no credentials are provided in config, falls back to GOOGLE_APPLICATION_CREDENTIALS or ADC. * * @public */ export class GoogleServiceAccountStrategy implements AuthenticationStrategy { + private readonly credentials?: string; + + constructor(opts: { config: Config }) { + this.credentials = opts.config.getOptionalString( + 'kubernetes.googleServiceAccountCredentials', + ); + } public async getCredential(): Promise { - const client = new container.v1.ClusterManagerClient(); + let client: container.v1.ClusterManagerClient; + + if (this.credentials) { + // Use credentials from config + try { + const credentialsObject = JSON.parse(this.credentials); + + client = new container.v1.ClusterManagerClient({ + credentials: credentialsObject, + }); + } catch (error) { + throw new Error( + `Failed to parse Google Service Account credentials from config: ${ + error instanceof Error ? error.message : 'Invalid JSON' + }`, + ); + } + } else { + // Fall back to Application Default Credentials or GOOGLE_APPLICATION_CREDENTIALS + client = new container.v1.ClusterManagerClient(); + } + const token = await client.auth.getAccessToken(); if (!token) { throw new Error( - 'Unable to obtain access token for the current Google Application Default Credentials', + 'Unable to obtain access token for Google Cloud authentication. Check your credentials configuration.', ); } return { type: 'bearer token', token }; diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index f86dd6cebb..2cf0bcf04c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -462,7 +462,9 @@ export class KubernetesBuilder { aws: new AwsIamStrategy({ config: this.env.config }), azure: new AzureIdentityStrategy(this.env.logger), google: new GoogleStrategy(), - googleServiceAccount: new GoogleServiceAccountStrategy(), + googleServiceAccount: new GoogleServiceAccountStrategy({ + config: this.env.config, + }), localKubectlProxy: new AnonymousStrategy(), oidc: new OidcStrategy(), serviceAccount: new ServiceAccountStrategy(),