diff --git a/.changeset/afraid-apes-smell.md b/.changeset/afraid-apes-smell.md new file mode 100644 index 0000000000..2654351ad8 --- /dev/null +++ b/.changeset/afraid-apes-smell.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-common': patch +--- + +Loosened the type of the `auth` field in the body of requests to the `retrieveObjectsByServiceId` endpoint. Now any JSON object is allowed, which should make it easier for integrators to write their own custom auth strategies for Kubernetes. diff --git a/.changeset/new-frogs-punch.md b/.changeset/new-frogs-punch.md new file mode 100644 index 0000000000..f4390e72c6 --- /dev/null +++ b/.changeset/new-frogs-punch.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +--- + +Integrators can now bring their own auth strategies through the use of the `addAuthStrategy` method on `KubernetesBuilder`. + +**BREAKING** on the slight chance you were using the `setAuthTranslatorMap` method on `KubernetesBuilder`, it has been removed along with the entire `KubernetesAuthTranslator` interface. This notion has been replaced with the more focused concept of an `AuthenticationStrategy`. Converting a translator to a strategy should not be especially difficult. diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index ad8ff32891..051706bbd5 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -22,51 +22,60 @@ import type { RequestHandler } from 'express'; import { TokenCredential } from '@azure/identity'; // @public (undocumented) -export class AksKubernetesAuthTranslator { +export class AksStrategy implements AuthenticationStrategy { // (undocumented) - decorateClusterDetailsWithAuth( + getCredential( + _: ClusterDetails, + requestAuth: KubernetesRequestAuth, + ): Promise; + // (undocumented) + validateCluster(): Error[]; +} + +// @public (undocumented) +export class AnonymousStrategy implements AuthenticationStrategy { + // (undocumented) + getCredential(): Promise; + // (undocumented) + validateCluster(): Error[]; +} + +// @public (undocumented) +export interface AuthenticationStrategy { + // (undocumented) + getCredential( clusterDetails: ClusterDetails, - auth: KubernetesRequestAuth, - ): Promise; + authConfig: KubernetesRequestAuth, + ): Promise; + // (undocumented) + validateCluster(authMetadata: AuthMetadata): Error[]; } -// @public (undocumented) -export interface AWSClusterDetails extends ClusterDetails { - // (undocumented) - assumeRole?: string; - // (undocumented) - externalId?: string; -} +// @public +export type AuthMetadata = Record; // @public (undocumented) -export class AwsIamKubernetesAuthTranslator - implements KubernetesAuthTranslator -{ +export class AwsIamStrategy implements AuthenticationStrategy { constructor(opts: { config: Config }); // (undocumented) - decorateClusterDetailsWithAuth( - clusterDetails: AWSClusterDetails, - ): Promise; + getCredential(clusterDetails: ClusterDetails): Promise; + // (undocumented) + validateCluster(): Error[]; } // @public (undocumented) -export interface AzureClusterDetails extends ClusterDetails {} - -// @public (undocumented) -export class AzureIdentityKubernetesAuthTranslator - implements KubernetesAuthTranslator -{ +export class AzureIdentityStrategy implements AuthenticationStrategy { constructor(logger: Logger, tokenCredential?: TokenCredential); // (undocumented) - decorateClusterDetailsWithAuth( - clusterDetails: AzureClusterDetails, - ): Promise; + getCredential(): Promise; + // (undocumented) + validateCluster(): Error[]; } // @public (undocumented) export interface ClusterDetails { // (undocumented) - authProvider: string; + authMetadata: AuthMetadata; // (undocumented) caData?: string | undefined; // (undocumented) @@ -76,9 +85,6 @@ export interface ClusterDetails { dashboardParameters?: JsonObject; dashboardUrl?: string; name: string; - oidcTokenProvider?: string | undefined; - // (undocumented) - serviceAccountToken?: string | undefined; skipMetricsLookup?: boolean; // (undocumented) skipTLSVerify?: boolean; @@ -105,21 +111,21 @@ export interface CustomResourcesByEntity extends KubernetesObjectsByEntity { export const DEFAULT_OBJECTS: ObjectToFetch[]; // @public -export class DispatchingKubernetesAuthTranslator - implements KubernetesAuthTranslator -{ - constructor(options: DispatchingKubernetesAuthTranslatorOptions); +export class DispatchStrategy implements AuthenticationStrategy { + constructor(options: DispatchStrategyOptions); // (undocumented) - decorateClusterDetailsWithAuth( + getCredential( clusterDetails: ClusterDetails, auth: KubernetesRequestAuth, - ): Promise; + ): Promise; + // (undocumented) + validateCluster(authMetadata: AuthMetadata): Error[]; } // @public (undocumented) -export type DispatchingKubernetesAuthTranslatorOptions = { - authTranslatorMap: { - [key: string]: KubernetesAuthTranslator; +export type DispatchStrategyOptions = { + authStrategyMap: { + [key: string]: AuthenticationStrategy; }; }; @@ -132,27 +138,22 @@ export interface FetchResponseWrapper { } // @public (undocumented) -export interface GKEClusterDetails extends ClusterDetails {} - -// @public (undocumented) -export class GoogleKubernetesAuthTranslator - implements KubernetesAuthTranslator -{ +export class GoogleServiceAccountStrategy implements AuthenticationStrategy { // (undocumented) - decorateClusterDetailsWithAuth( - clusterDetails: GKEClusterDetails, - authConfig: KubernetesRequestAuth, - ): Promise; + getCredential(): Promise; + // (undocumented) + validateCluster(): Error[]; } // @public (undocumented) -export class GoogleServiceAccountAuthTranslator - implements KubernetesAuthTranslator -{ +export class GoogleStrategy implements AuthenticationStrategy { // (undocumented) - decorateClusterDetailsWithAuth( - clusterDetails: GKEClusterDetails, - ): Promise; + getCredential( + _: ClusterDetails, + requestAuth: KubernetesRequestAuth, + ): Promise; + // (undocumented) + validateCluster(): Error[]; } // @public @@ -161,23 +162,16 @@ export const HEADER_KUBERNETES_AUTH: string; // @public export const HEADER_KUBERNETES_CLUSTER: string; -// @public (undocumented) -export interface KubernetesAuthTranslator { - // (undocumented) - decorateClusterDetailsWithAuth( - clusterDetails: ClusterDetails, - authConfig: KubernetesRequestAuth, - ): Promise; -} - // @public (undocumented) export class KubernetesBuilder { constructor(env: KubernetesEnvironment); // (undocumented) + addAuthStrategy(key: string, strategy: AuthenticationStrategy): this; + // (undocumented) build(): KubernetesBuilderReturn; // (undocumented) - protected buildAuthTranslatorMap(): { - [key: string]: KubernetesAuthTranslator; + protected buildAuthStrategyMap(): { + [key: string]: AuthenticationStrategy; }; // (undocumented) protected buildClusterSupplier( @@ -226,8 +220,8 @@ export class KubernetesBuilder { clusterSupplier: KubernetesClustersSupplier, ): Promise; // (undocumented) - protected getAuthTranslatorMap(): { - [key: string]: KubernetesAuthTranslator; + protected getAuthStrategyMap(): { + [key: string]: AuthenticationStrategy; }; // (undocumented) protected getClusterSupplier(): KubernetesClustersSupplier; @@ -249,8 +243,8 @@ export class KubernetesBuilder { // (undocumented) protected getServiceLocatorMethod(): ServiceLocatorMethod; // (undocumented) - setAuthTranslatorMap(authTranslatorMap: { - [key: string]: KubernetesAuthTranslator; + setAuthStrategyMap(authStrategyMap: { + [key: string]: AuthenticationStrategy; }): void; // (undocumented) setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; @@ -275,8 +269,8 @@ export type KubernetesBuilderReturn = Promise<{ proxy: KubernetesProxy; objectsProvider: KubernetesObjectsProvider; serviceLocator: KubernetesServiceLocator; - authTranslatorMap: { - [key: string]: KubernetesAuthTranslator; + authStrategyMap: { + [key: string]: AuthenticationStrategy; }; }>; @@ -285,6 +279,16 @@ export interface KubernetesClustersSupplier { getClusters(): Promise; } +// @public +export type KubernetesCredential = + | { + type: 'bearer token'; + token: string; + } + | { + type: 'anonymous'; + }; + // @public (undocumented) export interface KubernetesEnvironment { // (undocumented) @@ -306,6 +310,7 @@ export interface KubernetesFetcher { // (undocumented) fetchPodMetricsByNamespaces( clusterDetails: ClusterDetails, + credential: KubernetesCredential, namespaces: Set, labelSelector?: string, ): Promise; @@ -381,7 +386,7 @@ export type KubernetesProxyCreateRequestHandlerOptions = { export type KubernetesProxyOptions = { logger: Logger; clusterSupplier: KubernetesClustersSupplier; - authTranslator: KubernetesAuthTranslator; + authStrategy: AuthenticationStrategy; }; // @public @@ -395,22 +400,12 @@ export interface KubernetesServiceLocator { }>; } -// @public (undocumented) -export class NoopKubernetesAuthTranslator implements KubernetesAuthTranslator { - // (undocumented) - decorateClusterDetailsWithAuth( - clusterDetails: ServiceAccountClusterDetails, - ): Promise; -} - // @public (undocumented) export interface ObjectFetchParams { // (undocumented) - clusterDetails: - | AWSClusterDetails - | GKEClusterDetails - | ServiceAccountClusterDetails - | ClusterDetails; + clusterDetails: ClusterDetails; + // (undocumented) + credential: KubernetesCredential; // (undocumented) customResources: CustomResource[]; // (undocumented) @@ -439,12 +434,14 @@ export interface ObjectToFetch { } // @public (undocumented) -export class OidcKubernetesAuthTranslator implements KubernetesAuthTranslator { +export class OidcStrategy implements AuthenticationStrategy { // (undocumented) - decorateClusterDetailsWithAuth( + getCredential( clusterDetails: ClusterDetails, authConfig: KubernetesRequestAuth, - ): Promise; + ): Promise; + // (undocumented) + validateCluster(authMetadata: AuthMetadata): Error[]; } // @public (undocumented) @@ -464,7 +461,12 @@ export interface RouterOptions { } // @public (undocumented) -export interface ServiceAccountClusterDetails extends ClusterDetails {} +export class ServiceAccountStrategy implements AuthenticationStrategy { + // (undocumented) + getCredential(clusterDetails: ClusterDetails): Promise; + // (undocumented) + validateCluster(): Error[]; +} // @public (undocumented) export type ServiceLocatorMethod = 'multiTenant' | 'http'; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AksKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/auth/AksStrategy.test.ts similarity index 66% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/AksKubernetesAuthTranslator.test.ts rename to plugins/kubernetes-backend/src/auth/AksStrategy.test.ts index d0d3cf87bb..dfd8c85ce4 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AksKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/auth/AksStrategy.test.ts @@ -13,17 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AksKubernetesAuthTranslator } from './AksKubernetesAuthTranslator'; +import { AksStrategy } from './AksStrategy'; -describe('AksKubernetesAuthTranslator', () => { +describe('AksStrategy', () => { it('uses auth.aks value as bearer token', async () => { - const translator = new AksKubernetesAuthTranslator(); + const strategy = new AksStrategy(); - const details = await translator.decorateClusterDetailsWithAuth( - { name: '', authProvider: 'aks', url: '' }, + const credential = await strategy.getCredential( + { name: '', url: '', authMetadata: {} }, { aks: 'aksToken' }, ); - expect(details.serviceAccountToken).toBe('aksToken'); + expect(credential).toStrictEqual({ + type: 'bearer token', + token: 'aksToken', + }); }); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AksKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/auth/AksStrategy.ts similarity index 62% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/AksKubernetesAuthTranslator.ts rename to plugins/kubernetes-backend/src/auth/AksStrategy.ts index 13d784293e..07bdf2efe0 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AksKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/auth/AksStrategy.ts @@ -14,17 +14,24 @@ * limitations under the License. */ import { ClusterDetails } from '../types/types'; +import { AuthenticationStrategy, KubernetesCredential } from './types'; import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; /** * * @public */ -export class AksKubernetesAuthTranslator { - async decorateClusterDetailsWithAuth( - clusterDetails: ClusterDetails, - auth: KubernetesRequestAuth, - ): Promise { - return { ...clusterDetails, serviceAccountToken: auth.aks }; +export class AksStrategy implements AuthenticationStrategy { + public async getCredential( + _: ClusterDetails, + requestAuth: KubernetesRequestAuth, + ): Promise { + const token = requestAuth.aks; + return token + ? { type: 'bearer token', token: token as string } + : { type: 'anonymous' }; + } + public validateCluster(): Error[] { + return []; } } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts b/plugins/kubernetes-backend/src/auth/AnonymousStrategy.ts similarity index 67% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts rename to plugins/kubernetes-backend/src/auth/AnonymousStrategy.ts index efbbff8250..30a9593773 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts +++ b/plugins/kubernetes-backend/src/auth/AnonymousStrategy.ts @@ -14,16 +14,18 @@ * limitations under the License. */ -import { ClusterDetails } from '../types/types'; -import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; +import { AuthenticationStrategy, KubernetesCredential } from './types'; /** * * @public */ -export interface KubernetesAuthTranslator { - decorateClusterDetailsWithAuth( - clusterDetails: ClusterDetails, - authConfig: KubernetesRequestAuth, - ): Promise; +export class AnonymousStrategy implements AuthenticationStrategy { + public async getCredential(): Promise { + return { type: 'anonymous' }; + } + + public validateCluster(): Error[] { + return []; + } } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/auth/AwsIamStrategy.test.ts similarity index 66% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts rename to plugins/kubernetes-backend/src/auth/AwsIamStrategy.test.ts index b080aa332b..873c78e177 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/auth/AwsIamStrategy.test.ts @@ -13,8 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; import { ConfigReader } from '@backstage/config'; +import { + ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE, + ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID, +} from '@backstage/plugin-kubernetes-common'; +import { AwsIamStrategy } from './AwsIamStrategy'; let presign = jest.fn(async () => ({ hostname: 'https://example.com', @@ -51,33 +55,37 @@ jest.mock('@aws-sdk/credential-providers', () => ({ }, })); -describe('AwsIamKubernetesAuthTranslator tests', () => { +describe('AwsIamStrategy tests', () => { beforeEach(() => {}); it('returns a signed url for AWS credentials without assume role', async () => { - const authTranslator = new AwsIamKubernetesAuthTranslator({ config }); + const strategy = new AwsIamStrategy({ config }); - const authPromise = authTranslator.decorateClusterDetailsWithAuth({ + const credential = await strategy.getCredential({ name: 'test-cluster', url: '', - authProvider: 'aws', + authMetadata: {}, + }); + expect(credential).toEqual({ + type: 'bearer token', + token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', }); - expect((await authPromise).serviceAccountToken).toEqual( - 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', - ); }); it('returns a signed url for AWS credentials with assume role', async () => { - const authTranslator = new AwsIamKubernetesAuthTranslator({ config }); + const strategy = new AwsIamStrategy({ config }); - const authPromise = authTranslator.decorateClusterDetailsWithAuth({ - assumeRole: 'SomeRole', + const credential = await strategy.getCredential({ name: 'test-cluster', url: '', - authProvider: 'aws', + authMetadata: { + [ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'SomeRole', + }, + }); + + expect(credential).toEqual({ + type: 'bearer token', + token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', }); - expect((await authPromise).serviceAccountToken).toEqual( - 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', - ); expect(fromTemporaryCredentials).toHaveBeenCalledWith({ clientConfig: { region: 'us-east-1', @@ -93,18 +101,20 @@ describe('AwsIamKubernetesAuthTranslator tests', () => { }); it('returns a signed url for AWS credentials and passes the external id', async () => { - const authTranslator = new AwsIamKubernetesAuthTranslator({ config }); + const strategy = new AwsIamStrategy({ config }); - const authPromise = authTranslator.decorateClusterDetailsWithAuth({ - assumeRole: 'SomeRole', - externalId: 'external-id', + const credential = await strategy.getCredential({ name: 'test-cluster', url: '', - authProvider: 'aws', + authMetadata: { + [ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'SomeRole', + [ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'external-id', + }, + }); + expect(credential).toEqual({ + type: 'bearer token', + token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', }); - expect((await authPromise).serviceAccountToken).toEqual( - 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', - ); expect(fromTemporaryCredentials).toHaveBeenCalledWith({ clientConfig: { region: 'us-east-1', @@ -126,12 +136,12 @@ describe('AwsIamKubernetesAuthTranslator tests', () => { }); }); it('throws the right error', async () => { - const authTranslator = new AwsIamKubernetesAuthTranslator({ config }); + const strategy = new AwsIamStrategy({ config }); await expect( - authTranslator.decorateClusterDetailsWithAuth({ + strategy.getCredential({ name: 'test-cluster', url: '', - authProvider: 'aws', + authMetadata: {}, }), ).rejects.toThrow('no way'); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/auth/AwsIamStrategy.ts similarity index 78% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts rename to plugins/kubernetes-backend/src/auth/AwsIamStrategy.ts index cc46fd567a..53b880d60c 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/auth/AwsIamStrategy.ts @@ -13,8 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AWSClusterDetails } from '../types/types'; -import { KubernetesAuthTranslator } from './types'; import { fromTemporaryCredentials } from '@aws-sdk/credential-providers'; import { SignatureV4 } from '@aws-sdk/signature-v4'; import { Sha256 } from '@aws-crypto/sha256-js'; @@ -23,6 +21,12 @@ import { DefaultAwsCredentialsManager, } from '@backstage/integration-aws-node'; import { Config } from '@backstage/config'; +import { + ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE, + ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID, +} from '@backstage/plugin-kubernetes-common'; +import { ClusterDetails } from '../types/types'; +import { AuthenticationStrategy, KubernetesCredential } from './types'; /** * @@ -40,14 +44,30 @@ const defaultRegion = 'us-east-1'; * * @public */ -export class AwsIamKubernetesAuthTranslator - implements KubernetesAuthTranslator -{ +export class AwsIamStrategy implements AuthenticationStrategy { private readonly credsManager: AwsCredentialsManager; + constructor(opts: { config: Config }) { this.credsManager = DefaultAwsCredentialsManager.fromConfig(opts.config); } + public async getCredential( + clusterDetails: ClusterDetails, + ): Promise { + return { + type: 'bearer token', + token: await this.getBearerToken( + clusterDetails.name, + clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE], + clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID], + ), + }; + } + + public validateCluster(): Error[] { + return []; + } + private async getBearerToken( clusterName: string, assumeRole?: string, @@ -108,20 +128,4 @@ export class AwsIamKubernetesAuthTranslator return `k8s-aws-v1.${Buffer.from(url).toString('base64url')}`; } - - async decorateClusterDetailsWithAuth( - clusterDetails: AWSClusterDetails, - ): Promise { - const clusterDetailsWithAuthToken: AWSClusterDetails = Object.assign( - {}, - clusterDetails, - ); - - clusterDetailsWithAuthToken.serviceAccountToken = await this.getBearerToken( - clusterDetails.name, - clusterDetails.assumeRole, - clusterDetails.externalId, - ); - return clusterDetailsWithAuthToken; - } } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/auth/AzureIdentityStrategy.test.ts similarity index 51% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts rename to plugins/kubernetes-backend/src/auth/AzureIdentityStrategy.test.ts index 704551eb9f..7e1f99aeeb 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/auth/AzureIdentityStrategy.test.ts @@ -16,7 +16,7 @@ import { AccessToken, TokenCredential } from '@azure/identity'; import { getVoidLogger } from '@backstage/backend-common'; -import { AzureIdentityKubernetesAuthTranslator } from './AzureIdentityKubernetesAuthTranslator'; +import { AzureIdentityStrategy } from './AzureIdentityStrategy'; const logger = getVoidLogger(); @@ -39,102 +39,94 @@ class StaticTokenCredential implements TokenCredential { } } -describe('AzureIdentityKubernetesAuthTranslator tests', () => { - const cd = { - authProvider: 'Azure', - name: 'My Cluster', - url: 'mycluster.privatelink.westeurope.azmk8s.io', - }; - +describe('AzureIdentityStrategy tests', () => { afterEach(() => { jest.useRealTimers(); }); - it('should decorate cluster with Azure token', async () => { - const authTranslator = new AzureIdentityKubernetesAuthTranslator( + it('should get Azure token', async () => { + const strategy = new AzureIdentityStrategy( logger, new StaticTokenCredential(5 * 60 * 1000), ); - const response = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + const credential = await strategy.getCredential(); + expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' }); }); it('should re-use token before expiry', async () => { - const authTranslator = new AzureIdentityKubernetesAuthTranslator( + const strategy = new AzureIdentityStrategy( logger, new StaticTokenCredential(20 * 60 * 1000), ); - const response = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + const credential = await strategy.getCredential(); + expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' }); - const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response2.serviceAccountToken).toEqual('MY_TOKEN_1'); + const credential2 = await strategy.getCredential(); + expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' }); }); it('should issue new token 15 minutes befory expiry', async () => { jest.useFakeTimers(); - const authTranslator = new AzureIdentityKubernetesAuthTranslator( + const strategy = new AzureIdentityStrategy( logger, new StaticTokenCredential(16 * 60 * 1000), // token expires in 16min ); - const response = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + const credential = await strategy.getCredential(); + expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' }); jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins - const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); + const credential2 = await strategy.getCredential(); + expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' }); }); it('should re-use existing token if there is afailure', async () => { jest.useFakeTimers(); - const authTranslator = new AzureIdentityKubernetesAuthTranslator( + const strategy = new AzureIdentityStrategy( logger, new StaticTokenCredential(16 * 60 * 1000), // new tokens expires in 16min ); - const response = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + const credential = await strategy.getCredential(); + expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' }); jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min - const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); + const credential2 = await strategy.getCredential(); + expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' }); jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min - const response3 = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response3.serviceAccountToken).toEqual('MY_TOKEN_2'); + const credential3 = await strategy.getCredential(); + expect(credential3).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' }); - const response4 = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response4.serviceAccountToken).toEqual('MY_TOKEN_4'); + const credential4 = await strategy.getCredential(); + expect(credential4).toEqual({ type: 'bearer token', token: 'MY_TOKEN_4' }); }); it('should throw if existing token expired and failed to fetch a new one', async () => { jest.useFakeTimers(); - const authTranslator = new AzureIdentityKubernetesAuthTranslator( + const strategy = new AzureIdentityStrategy( logger, new StaticTokenCredential(16 * 60 * 1000), // new tokens expires in 16min ); - const response = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + const credential = await strategy.getCredential(); + expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' }); jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min - const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); - expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); + const credential2 = await strategy.getCredential(); + expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' }); jest.setSystemTime(Date.now() + 17 * 60 * 1000); // advance time by 17min - await expect( - authTranslator.decorateClusterDetailsWithAuth(cd), - ).rejects.toThrow(); + await expect(strategy.getCredential()).rejects.toThrow(); }); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/auth/AzureIdentityStrategy.ts similarity index 78% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts rename to plugins/kubernetes-backend/src/auth/AzureIdentityStrategy.ts index 2633598d6c..d0eea3c13c 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/auth/AzureIdentityStrategy.ts @@ -15,8 +15,7 @@ */ import { Logger } from 'winston'; -import { KubernetesAuthTranslator } from './types'; -import { AzureClusterDetails } from '../types/types'; +import { AuthenticationStrategy, KubernetesCredential } from './types'; import { AccessToken, DefaultAzureCredential, @@ -29,9 +28,7 @@ const aksScope = '6dae42f8-4368-4678-94ff-3960e28e3630/.default'; // This scope * * @public */ -export class AzureIdentityKubernetesAuthTranslator - implements KubernetesAuthTranslator -{ +export class AzureIdentityStrategy implements AuthenticationStrategy { private accessToken: AccessToken = { token: '', expiresOnTimestamp: 0 }; private newTokenPromise: Promise | undefined; @@ -40,28 +37,22 @@ export class AzureIdentityKubernetesAuthTranslator private readonly tokenCredential: TokenCredential = new DefaultAzureCredential(), ) {} - async decorateClusterDetailsWithAuth( - clusterDetails: AzureClusterDetails, - ): Promise { - const clusterDetailsWithAuthToken: AzureClusterDetails = Object.assign( - {}, - clusterDetails, - ); - - clusterDetailsWithAuthToken.serviceAccountToken = await this.getToken(); - return clusterDetailsWithAuthToken; - } - - private async getToken(): Promise { + public async getCredential(): Promise { if (!this.tokenRequiresRefresh()) { - return this.accessToken.token; + return { type: 'bearer token', token: this.accessToken.token }; } if (!this.newTokenPromise) { this.newTokenPromise = this.fetchNewToken(); } - return this.newTokenPromise; + return this.newTokenPromise + ? { type: 'bearer token', token: await this.newTokenPromise } + : { type: 'anonymous' }; + } + + public validateCluster(): Error[] { + return []; } private async fetchNewToken(): Promise { diff --git a/plugins/kubernetes-backend/src/auth/DispatchStrategy.test.ts b/plugins/kubernetes-backend/src/auth/DispatchStrategy.test.ts new file mode 100644 index 0000000000..7202311d6f --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/DispatchStrategy.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 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 { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, + KubernetesRequestAuth, +} from '@backstage/plugin-kubernetes-common'; +import { DispatchStrategy } from './DispatchStrategy'; +import { ClusterDetails } from '../types'; +import { AuthenticationStrategy } from './types'; + +describe('getCredential', () => { + let strategy: DispatchStrategy; + let mockStrategy: jest.Mocked; + const authObject: KubernetesRequestAuth = {}; + + beforeEach(() => { + mockStrategy = { + getCredential: jest.fn(), + validateCluster: jest.fn(), + }; + strategy = new DispatchStrategy({ + authStrategyMap: { google: mockStrategy }, + }); + }); + + it('gets credential if specified auth provider is in the strategy map', async () => { + const clusterDetails: ClusterDetails = { + url: 'notanything.com', + name: 'randomName', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, + }; + mockStrategy.getCredential.mockResolvedValue({ + type: 'bearer token', + token: 'added by mock strategy', + }); + + const returnedValue = await strategy.getCredential( + clusterDetails, + authObject, + ); + + expect(mockStrategy.getCredential).toHaveBeenCalledWith( + clusterDetails, + authObject, + ); + expect(returnedValue).toStrictEqual({ + type: 'bearer token', + token: 'added by mock strategy', + }); + }); + + it('throws an error when asked for a strategy for an unsupported auth type', () => { + expect(() => + strategy.getCredential( + { + name: 'test-cluster', + url: 'anything.com', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'linode' }, + }, + authObject, + ), + ).toThrow( + 'authProvider "linode" has no AuthenticationStrategy associated with it', + ); + }); +}); diff --git a/plugins/kubernetes-backend/src/auth/DispatchStrategy.ts b/plugins/kubernetes-backend/src/auth/DispatchStrategy.ts new file mode 100644 index 0000000000..f12e49b577 --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/DispatchStrategy.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 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 { AuthenticationStrategy, KubernetesCredential } from './types'; +import { AuthMetadata, ClusterDetails } from '../types'; +import { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, + KubernetesRequestAuth, +} from '@backstage/plugin-kubernetes-common'; + +/** + * + * @public + */ +export type DispatchStrategyOptions = { + authStrategyMap: { + [key: string]: AuthenticationStrategy; + }; +}; +/** + * used to direct a KubernetesAuthProvider to its corresponding AuthenticationStrategy + * @public + */ +export class DispatchStrategy implements AuthenticationStrategy { + private readonly strategyMap: { [key: string]: AuthenticationStrategy }; + + constructor(options: DispatchStrategyOptions) { + this.strategyMap = options.authStrategyMap; + } + + public getCredential( + clusterDetails: ClusterDetails, + auth: KubernetesRequestAuth, + ): Promise { + const authProvider = + clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER]; + if (this.strategyMap[authProvider]) { + return this.strategyMap[authProvider].getCredential(clusterDetails, auth); + } + throw new Error( + `authProvider "${authProvider}" has no AuthenticationStrategy associated with it`, + ); + } + + public validateCluster(authMetadata: AuthMetadata): Error[] { + const authProvider = authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER]; + const strategy = this.strategyMap[authProvider]; + if (!strategy) { + return [ + new Error( + `authProvider "${authProvider}" has no config associated with it`, + ), + ]; + } + return strategy.validateCluster(authMetadata); + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleServiceAccountAuthProvider.ts b/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.ts similarity index 58% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleServiceAccountAuthProvider.ts rename to plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.ts index cca510ad6c..2a44a31d6c 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleServiceAccountAuthProvider.ts +++ b/plugins/kubernetes-backend/src/auth/GoogleServiceAccountStrategy.ts @@ -13,34 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { KubernetesAuthTranslator } from './types'; -import { GKEClusterDetails } from '../types/types'; +import { AuthenticationStrategy, KubernetesCredential } from './types'; import * as container from '@google-cloud/container'; /** * * @public */ -export class GoogleServiceAccountAuthTranslator - implements KubernetesAuthTranslator -{ - async decorateClusterDetailsWithAuth( - clusterDetails: GKEClusterDetails, - ): Promise { - const clusterDetailsWithAuthToken: GKEClusterDetails = Object.assign( - {}, - clusterDetails, - ); +export class GoogleServiceAccountStrategy implements AuthenticationStrategy { + public async getCredential(): Promise { const client = new container.v1.ClusterManagerClient(); - const accessToken = await client.auth.getAccessToken(); + const token = await client.auth.getAccessToken(); - if (accessToken) { - clusterDetailsWithAuthToken.serviceAccountToken = accessToken; - } else { + if (!token) { throw new Error( 'Unable to obtain access token for the current Google Application Default Credentials', ); } - return clusterDetailsWithAuthToken; + return { type: 'bearer token', token }; + } + + public validateCluster(): Error[] { + return []; } } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/auth/GoogleStrategy.ts similarity index 55% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts rename to plugins/kubernetes-backend/src/auth/GoogleStrategy.ts index e8dae56997..c1290dc1fe 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/auth/GoogleStrategy.ts @@ -14,34 +14,28 @@ * limitations under the License. */ -import { KubernetesAuthTranslator } from './types'; -import { GKEClusterDetails } from '../types/types'; +import { AuthenticationStrategy, KubernetesCredential } from './types'; +import { ClusterDetails } from '../types/types'; import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; /** * * @public */ -export class GoogleKubernetesAuthTranslator - implements KubernetesAuthTranslator -{ - async decorateClusterDetailsWithAuth( - clusterDetails: GKEClusterDetails, - authConfig: KubernetesRequestAuth, - ): Promise { - const clusterDetailsWithAuthToken: GKEClusterDetails = Object.assign( - {}, - clusterDetails, - ); - const authToken: string | undefined = authConfig.google; - - if (authToken) { - clusterDetailsWithAuthToken.serviceAccountToken = authToken; - } else { +export class GoogleStrategy implements AuthenticationStrategy { + public async getCredential( + _: ClusterDetails, + requestAuth: KubernetesRequestAuth, + ): Promise { + const token = requestAuth.google; + if (!token) { throw new Error( 'Google token not found under auth.google in request body', ); } - return clusterDetailsWithAuthToken; + return { type: 'bearer token', token: token as string }; + } + public validateCluster(): Error[] { + return []; } } diff --git a/plugins/kubernetes-backend/src/auth/OidcStrategy.test.ts b/plugins/kubernetes-backend/src/auth/OidcStrategy.test.ts new file mode 100644 index 0000000000..46213d9f3e --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/OidcStrategy.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 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 { ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER } from '@backstage/plugin-kubernetes-common'; +import { OidcStrategy } from './OidcStrategy'; + +describe('OidcStrategy', () => { + let strategy: OidcStrategy; + beforeEach(() => { + strategy = new OidcStrategy(); + }); + + describe('getCredential', () => { + it('returns auth token', async () => { + const credential = await strategy.getCredential( + { + name: 'test', + url: '', + authMetadata: { + [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'okta', + }, + }, + { + oidc: { okta: 'fakeToken' }, + }, + ); + + expect(credential).toStrictEqual({ + type: 'bearer token', + token: 'fakeToken', + }); + }); + + it('fails when token provider is not configured', async () => { + await expect( + strategy.getCredential( + { + name: 'test', + url: '', + authMetadata: {}, + }, + {}, + ), + ).rejects.toThrow( + 'oidc authProvider requires a configured oidcTokenProvider', + ); + }); + + it('fails when token is not included in request body', async () => { + await expect( + strategy.getCredential( + { + name: 'test', + url: '', + authMetadata: { + [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'okta', + }, + }, + {}, + ), + ).rejects.toThrow('Auth token not found under oidc.okta in request body'); + }); + }); + + describe('validateCluster', () => { + it('fails when token provider is not specified', () => { + expect(strategy.validateCluster({})).toContainEqual( + new Error(`Must specify a token provider for 'oidc' strategy`), + ); + }); + }); +}); diff --git a/plugins/kubernetes-backend/src/auth/OidcStrategy.ts b/plugins/kubernetes-backend/src/auth/OidcStrategy.ts new file mode 100644 index 0000000000..445799cbb8 --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/OidcStrategy.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2020 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 { JsonObject } from '@backstage/types'; +import { + ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER, + KubernetesRequestAuth, +} from '@backstage/plugin-kubernetes-common'; +import { AuthenticationStrategy, KubernetesCredential } from './types'; +import { AuthMetadata, ClusterDetails } from '../types/types'; + +/** + * + * @public + */ +export class OidcStrategy implements AuthenticationStrategy { + public async getCredential( + clusterDetails: ClusterDetails, + authConfig: KubernetesRequestAuth, + ): Promise { + const oidcTokenProvider = + clusterDetails.authMetadata[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]; + + if (!oidcTokenProvider || oidcTokenProvider === '') { + throw new Error( + `oidc authProvider requires a configured oidcTokenProvider`, + ); + } + + const token = (authConfig.oidc as JsonObject | null)?.[oidcTokenProvider]; + + if (!token) { + throw new Error( + `Auth token not found under oidc.${oidcTokenProvider} in request body`, + ); + } + return { type: 'bearer token', token: token as string }; + } + + public validateCluster(authMetadata: AuthMetadata): Error[] { + const oidcTokenProvider = + authMetadata[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]; + if (!oidcTokenProvider || oidcTokenProvider === '') { + return [new Error(`Must specify a token provider for 'oidc' strategy`)]; + } + return []; + } +} diff --git a/plugins/kubernetes-backend/src/auth/ServiceAccountStrategy.test.ts b/plugins/kubernetes-backend/src/auth/ServiceAccountStrategy.test.ts new file mode 100644 index 0000000000..b6b9d0efb8 --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/ServiceAccountStrategy.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2023 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 { ServiceAccountStrategy } from './ServiceAccountStrategy'; +import mockFs from 'mock-fs'; + +describe('ServiceAccountStrategy', () => { + describe('#getCredential', () => { + it('reads bearer token from config', async () => { + const strategy = new ServiceAccountStrategy(); + + const credential = await strategy.getCredential({ + name: '', + url: '', + authMetadata: { serviceAccountToken: 'from config' }, + }); + + expect(credential).toStrictEqual({ + type: 'bearer token', + token: 'from config', + }); + }); + describe('when serviceAccountToken is absent from config', () => { + afterEach(() => { + mockFs.restore(); + }); + + it('reads in-cluster token', async () => { + mockFs({ + '/var/run/secrets/kubernetes.io/serviceaccount/token': + 'in-cluster-token', + }); + const strategy = new ServiceAccountStrategy(); + + const credential = await strategy.getCredential({ + name: '', + url: '', + authMetadata: {}, + }); + + expect(credential).toStrictEqual({ + type: 'bearer token', + token: 'in-cluster-token', + }); + }); + }); + }); +}); diff --git a/plugins/kubernetes-backend/src/auth/ServiceAccountStrategy.ts b/plugins/kubernetes-backend/src/auth/ServiceAccountStrategy.ts new file mode 100644 index 0000000000..714b2a94d1 --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/ServiceAccountStrategy.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 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 { KubeConfig, User } from '@kubernetes/client-node'; +import fs from 'fs-extra'; +import { AuthenticationStrategy, KubernetesCredential } from './types'; +import { ClusterDetails } from '../types/types'; + +/** + * + * @public + */ +export class ServiceAccountStrategy implements AuthenticationStrategy { + public async getCredential( + clusterDetails: ClusterDetails, + ): Promise { + const token = clusterDetails.authMetadata.serviceAccountToken; + if (token) { + return { type: 'bearer token', token }; + } + const kc = new KubeConfig(); + kc.loadFromCluster(); + // loadFromCluster is guaranteed to populate the user + const user = kc.getCurrentUser() as User; + + return { + type: 'bearer token', + token: fs.readFileSync(user.authProvider.config.tokenFile).toString(), + }; + } + + public validateCluster(): Error[] { + return []; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/index.ts b/plugins/kubernetes-backend/src/auth/index.ts similarity index 60% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/index.ts rename to plugins/kubernetes-backend/src/auth/index.ts index 71dcedfee6..bcdd620243 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/index.ts +++ b/plugins/kubernetes-backend/src/auth/index.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -export * from './AksKubernetesAuthTranslator'; -export * from './AwsIamKubernetesAuthTranslator'; -export * from './AzureIdentityKubernetesAuthTranslator'; -export * from './GoogleKubernetesAuthTranslator'; -export * from './GoogleServiceAccountAuthProvider'; -export * from './DispatchingKubernetesAuthTranslator'; -export * from './NoopKubernetesAuthTranslator'; -export * from './OidcKubernetesAuthTranslator'; +export * from './AksStrategy'; +export * from './AnonymousStrategy'; +export * from './AwsIamStrategy'; +export * from './AzureIdentityStrategy'; +export * from './GoogleStrategy'; +export * from './GoogleServiceAccountStrategy'; +export * from './DispatchStrategy'; +export * from './ServiceAccountStrategy'; +export * from './OidcStrategy'; export * from './types'; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/NoopKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/auth/types.ts similarity index 53% rename from plugins/kubernetes-backend/src/kubernetes-auth-translator/NoopKubernetesAuthTranslator.ts rename to plugins/kubernetes-backend/src/auth/types.ts index 32aa141e50..fff9952bae 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/NoopKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/auth/types.ts @@ -14,17 +14,25 @@ * limitations under the License. */ -import { KubernetesAuthTranslator } from './types'; -import { ServiceAccountClusterDetails } from '../types/types'; +import { AuthMetadata, ClusterDetails } from '../types/types'; +import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; + +/** + * Authentication data used to make a request to Kubernetes + * @public + */ +export type KubernetesCredential = + | { type: 'bearer token'; token: string } + | { type: 'anonymous' }; /** * * @public */ -export class NoopKubernetesAuthTranslator implements KubernetesAuthTranslator { - async decorateClusterDetailsWithAuth( - clusterDetails: ServiceAccountClusterDetails, - ): Promise { - return clusterDetails; - } +export interface AuthenticationStrategy { + getCredential( + clusterDetails: ClusterDetails, + authConfig: KubernetesRequestAuth, + ): Promise; + validateCluster(authMetadata: AuthMetadata): Error[]; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.test.ts index f436024131..cd8195b9dc 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.test.ts @@ -15,22 +15,29 @@ */ import '@backstage/backend-common'; +import { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, + ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE, + ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID, + ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER, +} from '@backstage/plugin-kubernetes-common'; import { CatalogClusterLocator } from './CatalogClusterLocator'; import { CatalogApi } from '@backstage/catalog-client'; +import { ClusterDetails } from '../types/types'; const mockCatalogApi = { getEntityByRef: jest.fn(), getEntities: async () => ({ items: [ { - apiVersion: 'version', - kind: 'User', + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', metadata: { annotations: { 'kubernetes.io/api-server': 'https://apiserver.com', 'kubernetes.io/api-server-certificate-authority': 'caData', - 'kubernetes.io/auth-provider': 'oidc', - 'kubernetes.io/oidc-token-provider': 'google', + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'oidc', + [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google', 'kubernetes.io/skip-metrics-lookup': 'true', 'kubernetes.io/skip-tls-verify': 'true', 'kubernetes.io/dashboard-url': 'my-url', @@ -41,16 +48,16 @@ const mockCatalogApi = { }, }, { - apiVersion: 'version', - kind: 'User', + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', metadata: { annotations: { 'kubernetes.io/api-server': 'https://apiserver.com', 'kubernetes.io/api-server-certificate-authority': 'caData', - 'kubernetes.io/auth-provider': 'aws', - 'kubernetes.io/aws-assume-role': 'my-role', - 'kubernetes.io/aws-external-id': 'my-id', - 'kubernetes.io/oidc-token-provider': 'google', + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', + [ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'my-role', + [ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'my-id', + [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google', 'kubernetes.io/dashboard-url': 'my-url', 'kubernetes.io/dashboard-app': 'my-app', }, @@ -86,12 +93,20 @@ describe('CatalogClusterLocator', () => { const result = await clusterSupplier.getClusters(); expect(result).toHaveLength(2); - expect(result[0]).toStrictEqual({ + expect(result[0]).toStrictEqual({ name: 'owned', url: 'https://apiserver.com', caData: 'caData', - authProvider: 'oidc', - oidcTokenProvider: 'google', + authMetadata: { + 'kubernetes.io/api-server': 'https://apiserver.com', + 'kubernetes.io/api-server-certificate-authority': 'caData', + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'oidc', + [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google', + 'kubernetes.io/skip-metrics-lookup': 'true', + 'kubernetes.io/skip-tls-verify': 'true', + 'kubernetes.io/dashboard-url': 'my-url', + 'kubernetes.io/dashboard-app': 'my-app', + }, skipMetricsLookup: true, skipTLSVerify: true, dashboardUrl: 'my-url', @@ -105,14 +120,20 @@ describe('CatalogClusterLocator', () => { const result = await clusterSupplier.getClusters(); expect(result).toHaveLength(2); - expect(result[1]).toStrictEqual({ + expect(result[1]).toStrictEqual({ name: 'owned', url: 'https://apiserver.com', caData: 'caData', - authProvider: 'aws', - assumeRole: 'my-role', - externalId: 'my-id', - oidcTokenProvider: 'google', + authMetadata: { + 'kubernetes.io/api-server': 'https://apiserver.com', + 'kubernetes.io/api-server-certificate-authority': 'caData', + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', + [ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'my-role', + [ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'my-id', + [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google', + 'kubernetes.io/dashboard-url': 'my-url', + 'kubernetes.io/dashboard-app': 'my-app', + }, skipMetricsLookup: false, skipTLSVerify: false, dashboardUrl: 'my-url', diff --git a/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts index c48cc99eb6..f8abd5b203 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts @@ -14,23 +14,16 @@ * limitations under the License. */ -import { - AWSClusterDetails, - ClusterDetails, - KubernetesClustersSupplier, -} from '../types/types'; +import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { CATALOG_FILTER_EXISTS, CatalogApi } from '@backstage/catalog-client'; import { ANNOTATION_KUBERNETES_API_SERVER, ANNOTATION_KUBERNETES_API_SERVER_CA, ANNOTATION_KUBERNETES_AUTH_PROVIDER, - ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER, ANNOTATION_KUBERNETES_SKIP_METRICS_LOOKUP, ANNOTATION_KUBERNETES_SKIP_TLS_VERIFY, ANNOTATION_KUBERNETES_DASHBOARD_URL, ANNOTATION_KUBERNETES_DASHBOARD_APP, - ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID, - ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE, } from '@backstage/plugin-kubernetes-common'; export class CatalogClusterLocator implements KubernetesClustersSupplier { @@ -64,14 +57,9 @@ export class CatalogClusterLocator implements KubernetesClustersSupplier { const clusterDetails: ClusterDetails = { name: entity.metadata.name, url: entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER]!, + authMetadata: entity.metadata.annotations!, caData: entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER_CA]!, - authProvider: - entity.metadata.annotations![ANNOTATION_KUBERNETES_AUTH_PROVIDER]!, - oidcTokenProvider: - entity.metadata.annotations![ - ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER - ]!, skipMetricsLookup: entity.metadata.annotations![ ANNOTATION_KUBERNETES_SKIP_METRICS_LOOKUP @@ -86,20 +74,6 @@ export class CatalogClusterLocator implements KubernetesClustersSupplier { entity.metadata.annotations![ANNOTATION_KUBERNETES_DASHBOARD_APP]!, }; - if (clusterDetails.authProvider === 'aws') { - return { - ...clusterDetails, - assumeRole: - entity.metadata.annotations![ - ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE - ]!, - externalId: - entity.metadata.annotations![ - ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID - ]!, - } as AWSClusterDetails; - } - return clusterDetails; }); } diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index d84d7e39e8..258cddcd66 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -16,15 +16,31 @@ import '@backstage/backend-common'; import { ConfigReader, Config } from '@backstage/config'; +import { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, + ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE, + ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID, +} from '@backstage/plugin-kubernetes-common'; import { ConfigClusterLocator } from './ConfigClusterLocator'; +import { ClusterDetails } from '../types/types'; +import { AuthenticationStrategy } from '../auth'; describe('ConfigClusterLocator', () => { + let authStrategy: jest.Mocked; + + beforeEach(() => { + authStrategy = { + getCredential: jest.fn(), + validateCluster: jest.fn().mockReturnValue([]), + }; + }); + it('empty clusters returns empty cluster details', async () => { const config: Config = new ConfigReader({ clusters: [], }); - const sut = ConfigClusterLocator.fromConfig(config); + const sut = ConfigClusterLocator.fromConfig(config, authStrategy); const result = await sut.getClusters(); @@ -42,16 +58,17 @@ describe('ConfigClusterLocator', () => { ], }); - const sut = ConfigClusterLocator.fromConfig(config); + const sut = ConfigClusterLocator.fromConfig(config, authStrategy); const result = await sut.getClusters(); - expect(result).toStrictEqual([ + expect(result).toStrictEqual([ { name: 'cluster1', - serviceAccountToken: undefined, url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + }, skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, @@ -82,17 +99,19 @@ describe('ConfigClusterLocator', () => { ], }); - const sut = ConfigClusterLocator.fromConfig(config); + const sut = ConfigClusterLocator.fromConfig(config, authStrategy); const result = await sut.getClusters(); - expect(result).toStrictEqual([ + expect(result).toStrictEqual([ { name: 'cluster1', dashboardUrl: 'https://k8s.foo.com', - serviceAccountToken: 'token', url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + serviceAccountToken: 'token', + }, skipTLSVerify: false, skipMetricsLookup: true, caData: undefined, @@ -100,9 +119,8 @@ describe('ConfigClusterLocator', () => { }, { name: 'cluster2', - serviceAccountToken: undefined, url: 'http://localhost:8081', - authProvider: 'google', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: true, skipMetricsLookup: false, caData: undefined, @@ -111,74 +129,50 @@ describe('ConfigClusterLocator', () => { ]); }); - it('one aws cluster with assumeRole and one without', async () => { + it('copies "assumeRole" config value into metadata', async () => { const config: Config = new ConfigReader({ clusters: [ { name: 'cluster1', - serviceAccountToken: 'token', url: 'http://localhost:8080', authProvider: 'aws', - skipTLSVerify: false, - }, - { assumeRole: 'SomeRole', - name: 'cluster2', - url: 'http://localhost:8081', - authProvider: 'aws', - skipTLSVerify: true, - }, - { - assumeRole: 'SomeRole', - name: 'cluster2', - externalId: 'SomeExternalId', - url: 'http://localhost:8081', - authProvider: 'aws', - skipTLSVerify: true, }, ], }); - const sut = ConfigClusterLocator.fromConfig(config); - + const sut = ConfigClusterLocator.fromConfig(config, authStrategy); const result = await sut.getClusters(); - expect(result).toStrictEqual([ + expect(result).toMatchObject([ { - assumeRole: undefined, - name: 'cluster1', - serviceAccountToken: 'token', - externalId: undefined, - url: 'http://localhost:8080', - authProvider: 'aws', - skipTLSVerify: false, - skipMetricsLookup: false, - caData: undefined, - caFile: undefined, + authMetadata: expect.objectContaining({ + [ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'SomeRole', + }), }, + ]); + }); + + it('copies "externalId" config value into metadata', async () => { + const config: Config = new ConfigReader({ + clusters: [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'aws', + externalId: 'SomeExternalId', + }, + ], + }); + + const sut = ConfigClusterLocator.fromConfig(config, authStrategy); + const result = await sut.getClusters(); + + expect(result).toMatchObject([ { - assumeRole: 'SomeRole', - name: 'cluster2', - externalId: undefined, - serviceAccountToken: undefined, - url: 'http://localhost:8081', - authProvider: 'aws', - skipTLSVerify: true, - skipMetricsLookup: false, - caData: undefined, - caFile: undefined, - }, - { - assumeRole: 'SomeRole', - name: 'cluster2', - externalId: 'SomeExternalId', - url: 'http://localhost:8081', - serviceAccountToken: undefined, - authProvider: 'aws', - skipTLSVerify: true, - skipMetricsLookup: false, - caData: undefined, - caFile: undefined, + authMetadata: expect.objectContaining({ + [ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'SomeExternalId', + }), }, ]); }); @@ -200,16 +194,17 @@ describe('ConfigClusterLocator', () => { ], }); - const sut = ConfigClusterLocator.fromConfig(config); + const sut = ConfigClusterLocator.fromConfig(config, authStrategy); const result = await sut.getClusters(); - expect(result).toStrictEqual([ + expect(result).toStrictEqual([ { name: 'cluster1', - serviceAccountToken: undefined, url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + }, skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, @@ -237,16 +232,17 @@ describe('ConfigClusterLocator', () => { ], }); - const sut = ConfigClusterLocator.fromConfig(config); + const sut = ConfigClusterLocator.fromConfig(config, authStrategy); const result = await sut.getClusters(); - expect(result).toStrictEqual([ + expect(result).toStrictEqual([ { name: 'cluster1', - serviceAccountToken: undefined, url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + }, skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, @@ -257,21 +253,6 @@ describe('ConfigClusterLocator', () => { ]); }); - it('supports aks authProvider', async () => { - const cluster = { - name: 'aks-cluster', - url: 'https://aks.test', - authProvider: 'aks', - }; - const sut = ConfigClusterLocator.fromConfig( - new ConfigReader({ clusters: [cluster] }), - ); - - const result = await sut.getClusters(); - - expect(result).toMatchObject([cluster]); - }); - it('has cluster level defined customResources returns clusterDetails with those CRDs', async () => { const config: Config = new ConfigReader({ clusters: [ @@ -290,16 +271,17 @@ describe('ConfigClusterLocator', () => { ], }); - const sut = ConfigClusterLocator.fromConfig(config); + const sut = ConfigClusterLocator.fromConfig(config, authStrategy); const result = await sut.getClusters(); - expect(result).toStrictEqual([ + expect(result).toStrictEqual([ { name: 'cluster1', - serviceAccountToken: undefined, url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + }, skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, @@ -314,4 +296,21 @@ describe('ConfigClusterLocator', () => { }, ]); }); + + it('wraps validation errors from auth strategy', () => { + const config: Config = new ConfigReader({ + clusters: [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'authProvider', + }, + ], + }); + authStrategy.validateCluster.mockReturnValue([new Error('mock error')]); + + expect(() => ConfigClusterLocator.fromConfig(config, authStrategy)).toThrow( + `Invalid cluster 'cluster1': mock error`, + ); + }); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 4d49e2d128..7899d7762d 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -15,7 +15,14 @@ */ import { Config } from '@backstage/config'; +import { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, + ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE, + ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID, + ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER, +} from '@backstage/plugin-kubernetes-common'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; +import { AuthenticationStrategy } from '../auth'; export class ConfigClusterLocator implements KubernetesClustersSupplier { private readonly clusterDetails: ClusterDetails[]; @@ -24,21 +31,24 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { this.clusterDetails = clusterDetails; } - static fromConfig(config: Config): ConfigClusterLocator { - // TODO: Add validation that authProvider is required and serviceAccountToken - // is required if authProvider is serviceAccount + static fromConfig( + config: Config, + authStrategy: AuthenticationStrategy, + ): ConfigClusterLocator { return new ConfigClusterLocator( config.getConfigArray('clusters').map(c => { const authProvider = c.getString('authProvider'); const clusterDetails: ClusterDetails = { name: c.getString('name'), url: c.getString('url'), - serviceAccountToken: c.getOptionalString('serviceAccountToken'), skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, skipMetricsLookup: c.getOptionalBoolean('skipMetricsLookup') ?? false, caData: c.getOptionalString('caData'), caFile: c.getOptionalString('caFile'), - authProvider: authProvider, + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: authProvider, + ...ConfigClusterLocator.parseAuthMetadata(c), + }, }; const customResources = c.getOptionalConfigArray('customResources'); @@ -64,43 +74,48 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { clusterDetails.dashboardParameters = c.get('dashboardParameters'); } - switch (authProvider) { - case 'google': { - return clusterDetails; - } - case 'aws': { - const assumeRole = c.getOptionalString('assumeRole'); - const externalId = c.getOptionalString('externalId'); - - return { assumeRole, externalId, ...clusterDetails }; - } - case 'azure': { - return clusterDetails; - } - case 'oidc': { - const oidcTokenProvider = c.getString('oidcTokenProvider'); - - return { oidcTokenProvider, ...clusterDetails }; - } - case 'serviceAccount': { - return clusterDetails; - } - case 'googleServiceAccount': { - return clusterDetails; - } - case 'aks': { - return clusterDetails; - } - default: { - throw new Error( - `authProvider "${authProvider}" has no config associated with it`, - ); - } + const validationErrors = authStrategy.validateCluster( + clusterDetails.authMetadata, + ); + if (validationErrors.length !== 0) { + throw new Error( + `Invalid cluster '${clusterDetails.name}': ${validationErrors + .map(e => e.message) + .join(', ')}`, + ); } + return clusterDetails; }), ); } + private static parseAuthMetadata( + clusterConfig: Config, + ): Record | undefined { + const serviceAccountToken = clusterConfig.getOptionalString( + 'serviceAccountToken', + ); + const assumeRole = clusterConfig.getOptionalString('assumeRole'); + const externalId = clusterConfig.getOptionalString('externalId'); + const oidcTokenProvider = + clusterConfig.getOptionalString('oidcTokenProvider'); + + return serviceAccountToken || assumeRole || externalId || oidcTokenProvider + ? { + ...(serviceAccountToken && { serviceAccountToken }), + ...(assumeRole && { + [ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: assumeRole, + }), + ...(externalId && { + [ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: externalId, + }), + ...(oidcTokenProvider && { + [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: oidcTokenProvider, + }), + } + : undefined; + } + async getClusters(): Promise { return this.clusterDetails; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 11dc4572ea..f919561490 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common'; import '@backstage/backend-common'; import { ConfigReader, Config } from '@backstage/config'; import { GkeClusterLocator } from './GkeClusterLocator'; @@ -104,9 +105,9 @@ describe('GkeClusterLocator', () => { expect(result).toStrictEqual([ { - authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: false, skipMetricsLookup: true, }, @@ -141,9 +142,9 @@ describe('GkeClusterLocator', () => { expect(result).toStrictEqual([ { - authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: false, skipMetricsLookup: false, }, @@ -183,16 +184,16 @@ describe('GkeClusterLocator', () => { expect(result).toStrictEqual([ { - authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: false, skipMetricsLookup: false, }, { - authProvider: 'google', name: 'some-other-cluster', url: 'https://6.7.8.9', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: false, skipMetricsLookup: false, }, @@ -238,16 +239,16 @@ describe('GkeClusterLocator', () => { expect(result).toStrictEqual([ { - authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: false, skipMetricsLookup: false, }, { - authProvider: 'google', name: 'some-other-cluster', url: 'https://6.7.8.9', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: false, skipMetricsLookup: false, }, @@ -299,9 +300,9 @@ describe('GkeClusterLocator', () => { expect(result).toStrictEqual([ { - authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: false, skipMetricsLookup: false, }, @@ -363,9 +364,9 @@ describe('GkeClusterLocator', () => { expect(result).toStrictEqual([ { - authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify: false, skipMetricsLookup: true, dashboardApp: 'gke', diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 7a81b98032..74e7ebc9af 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -14,16 +14,13 @@ * limitations under the License. */ +import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common'; import { Config } from '@backstage/config'; import { ForwardedError } from '@backstage/errors'; import * as container from '@google-cloud/container'; import { Duration } from 'luxon'; import { runPeriodically } from '../service/runPeriodically'; -import { - ClusterDetails, - GKEClusterDetails, - KubernetesClustersSupplier, -} from '../types/types'; +import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; interface MatchResourceLabelEntry { key: string; @@ -43,7 +40,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { constructor( private readonly options: GkeClusterLocatorOptions, private readonly client: container.v1.ClusterManagerClient, - private clusterDetails: GKEClusterDetails[] | undefined = undefined, + private clusterDetails: ClusterDetails[] | undefined = undefined, private hasClusterDetails: boolean = false, ) {} @@ -124,7 +121,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { // TODO filter out clusters which don't have name or endpoint name: r.name ?? 'unknown', url: `https://${r.endpoint ?? ''}`, - authProvider: 'google', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipTLSVerify, skipMetricsLookup, ...(exposeDashboard diff --git a/plugins/kubernetes-backend/src/cluster-locator/LocalKubectlProxyLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/LocalKubectlProxyLocator.ts index b2c11fc744..9bbab42443 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/LocalKubectlProxyLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/LocalKubectlProxyLocator.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; export class LocalKubectlProxyClusterLocator @@ -26,7 +27,9 @@ export class LocalKubectlProxyClusterLocator { name: 'local', url: 'http:/localhost:8001', - authProvider: 'localKubectlProxy', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'localKubectlProxy', + }, skipMetricsLookup: true, }, ]; diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 6847b0e0a3..ded0faac5b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -15,8 +15,11 @@ */ import { Config, ConfigReader } from '@backstage/config'; -import { getCombinedClusterSupplier } from './index'; import { CatalogApi } from '@backstage/catalog-client'; +import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common'; +import { getCombinedClusterSupplier } from './index'; +import { ClusterDetails } from '../types/types'; +import { AuthenticationStrategy, DispatchStrategy } from '../auth'; describe('getCombinedClusterSupplier', () => { let catalogApi: CatalogApi; @@ -47,16 +50,26 @@ describe('getCombinedClusterSupplier', () => { }, 'ctx', ); + const mockStrategy: jest.Mocked = { + getCredential: jest.fn(), + validateCluster: jest.fn().mockReturnValue([]), + }; - const clusterSupplier = getCombinedClusterSupplier(config, catalogApi); + const clusterSupplier = getCombinedClusterSupplier( + config, + catalogApi, + mockStrategy, + ); const result = await clusterSupplier.getClusters(); - expect(result).toStrictEqual([ + expect(result).toStrictEqual([ { name: 'cluster1', - serviceAccountToken: 'token', url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + serviceAccountToken: 'token', + }, skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, @@ -64,9 +77,8 @@ describe('getCombinedClusterSupplier', () => { }, { name: 'cluster2', - serviceAccountToken: undefined, url: 'http://localhost:8081', - authProvider: 'google', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, @@ -77,35 +89,17 @@ describe('getCombinedClusterSupplier', () => { it('throws an error when using an unsupported cluster locator', async () => { const config: Config = new ConfigReader( - { - kubernetes: { - clusterLocatorMethods: [ - { - type: 'config', - clusters: [ - { - name: 'cluster1', - serviceAccountToken: 'token', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', - }, - { - name: 'cluster2', - url: 'http://localhost:8081', - authProvider: 'google', - }, - ], - }, - { - type: 'magic', - }, - ], - }, - }, + { kubernetes: { clusterLocatorMethods: [{ type: 'magic' }] } }, 'ctx', ); - expect(() => getCombinedClusterSupplier(config, catalogApi)).toThrow( + expect(() => + getCombinedClusterSupplier( + config, + catalogApi, + new DispatchStrategy({ authStrategyMap: {} }), + ), + ).toThrow( new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'), ); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index 62dfe15720..03a459b634 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -17,6 +17,7 @@ import { Config } from '@backstage/config'; import { Duration } from 'luxon'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; +import { AuthenticationStrategy } from '../auth/types'; import { ConfigClusterLocator } from './ConfigClusterLocator'; import { GkeClusterLocator } from './GkeClusterLocator'; import { CatalogClusterLocator } from './CatalogClusterLocator'; @@ -42,6 +43,7 @@ class CombinedClustersSupplier implements KubernetesClustersSupplier { export const getCombinedClusterSupplier = ( rootConfig: Config, catalogClient: CatalogApi, + authStrategy: AuthenticationStrategy, refreshInterval: Duration | undefined = undefined, ): KubernetesClustersSupplier => { const clusterSuppliers = rootConfig @@ -54,7 +56,10 @@ export const getCombinedClusterSupplier = ( case 'localKubectlProxy': return new LocalKubectlProxyClusterLocator(); case 'config': - return ConfigClusterLocator.fromConfig(clusterLocatorMethod); + return ConfigClusterLocator.fromConfig( + clusterLocatorMethod, + authStrategy, + ); case 'gke': return GkeClusterLocator.fromConfig( clusterLocatorMethod, diff --git a/plugins/kubernetes-backend/src/index.ts b/plugins/kubernetes-backend/src/index.ts index 67aefbd974..d32dfc24be 100644 --- a/plugins/kubernetes-backend/src/index.ts +++ b/plugins/kubernetes-backend/src/index.ts @@ -20,6 +20,6 @@ * @packageDocumentation */ -export * from './kubernetes-auth-translator'; +export * from './auth'; export * from './service'; export * from './types'; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/DispatchingKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/DispatchingKubernetesAuthTranslator.test.ts deleted file mode 100644 index 3132bdef76..0000000000 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/DispatchingKubernetesAuthTranslator.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 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 { DispatchingKubernetesAuthTranslator } from './DispatchingKubernetesAuthTranslator'; -import { ClusterDetails } from '../types'; -import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; -import { KubernetesAuthTranslator } from './types'; - -describe('decorateClusterDetailsWithAuth', () => { - let authTranslator: DispatchingKubernetesAuthTranslator; - let mockTranslator: jest.Mocked; - const authObject: KubernetesRequestAuth = {}; - - beforeEach(() => { - mockTranslator = { decorateClusterDetailsWithAuth: jest.fn() }; - authTranslator = new DispatchingKubernetesAuthTranslator({ - authTranslatorMap: { google: mockTranslator }, - }); - }); - - it('can decorate cluster details if the auth provider is in the translator map', async () => { - const expectedClusterDetails: ClusterDetails = { - url: 'notanything.com', - name: 'randomName', - authProvider: 'google', - serviceAccountToken: 'added by mock translator', - }; - - mockTranslator.decorateClusterDetailsWithAuth.mockResolvedValue( - expectedClusterDetails, - ); - - const returnedValue = await authTranslator.decorateClusterDetailsWithAuth( - { name: 'googleCluster', url: 'anything.com', authProvider: 'google' }, - authObject, - ); - - expect(mockTranslator.decorateClusterDetailsWithAuth).toHaveBeenCalledWith( - { name: 'googleCluster', url: 'anything.com', authProvider: 'google' }, - authObject, - ); - expect(returnedValue).toBe(expectedClusterDetails); - }); - - it('throws an error when asked for an auth translator for an unsupported auth type', () => { - expect(() => - authTranslator.decorateClusterDetailsWithAuth( - { - name: 'test-cluster', - url: 'anything.com', - authProvider: 'linode', - }, - authObject, - ), - ).toThrow( - 'authProvider "linode" has no KubernetesAuthTranslator associated with it', - ); - }); -}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/DispatchingKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/DispatchingKubernetesAuthTranslator.ts deleted file mode 100644 index 82d9538cac..0000000000 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/DispatchingKubernetesAuthTranslator.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 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 { KubernetesAuthTranslator } from './types'; -import { ClusterDetails } from '../types'; -import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; - -/** - * - * @public - */ -export type DispatchingKubernetesAuthTranslatorOptions = { - authTranslatorMap: { - [key: string]: KubernetesAuthTranslator; - }; -}; -/** - * used to direct a KubernetesAuthProvider to its corresponding KubernetesAuthTranslator - * @public - */ -export class DispatchingKubernetesAuthTranslator - implements KubernetesAuthTranslator -{ - private readonly translatorMap: { [key: string]: KubernetesAuthTranslator }; - - constructor(options: DispatchingKubernetesAuthTranslatorOptions) { - this.translatorMap = options.authTranslatorMap; - } - - public decorateClusterDetailsWithAuth( - clusterDetails: ClusterDetails, - auth: KubernetesRequestAuth, - ) { - if (this.translatorMap[clusterDetails.authProvider]) { - return this.translatorMap[ - clusterDetails.authProvider - ].decorateClusterDetailsWithAuth(clusterDetails, auth); - } - throw new Error( - `authProvider "${clusterDetails.authProvider}" has no KubernetesAuthTranslator associated with it`, - ); - } -} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts deleted file mode 100644 index 68f24bd020..0000000000 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2020 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 { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator'; -import { ClusterDetails } from '../types/types'; - -describe('OidcKubernetesAuthTranslator tests', () => { - const at = new OidcKubernetesAuthTranslator(); - const baseClusterDetails: ClusterDetails = { - name: 'test', - authProvider: 'oidc', - url: '', - }; - - it('returns cluster details with auth token', async () => { - const details = await at.decorateClusterDetailsWithAuth( - { - oidcTokenProvider: 'okta', - ...baseClusterDetails, - }, - { - oidc: { okta: 'fakeToken' }, - }, - ); - - expect(details.serviceAccountToken).toBe('fakeToken'); - }); - - it('returns error when oidcTokenProvider is not configured', async () => { - await expect( - at.decorateClusterDetailsWithAuth(baseClusterDetails, {}), - ).rejects.toThrow( - 'oidc authProvider requires a configured oidcTokenProvider', - ); - }); - - it('returns error when token is not included in request body', async () => { - await expect( - at.decorateClusterDetailsWithAuth( - { oidcTokenProvider: 'okta', ...baseClusterDetails }, - {}, - ), - ).rejects.toThrow('Auth token not found under oidc.okta in request body'); - }); -}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts deleted file mode 100644 index cee1ec1e0c..0000000000 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 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 { KubernetesAuthTranslator } from './types'; -import { ClusterDetails } from '../types/types'; -import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; - -/** - * - * @public - */ -export class OidcKubernetesAuthTranslator implements KubernetesAuthTranslator { - async decorateClusterDetailsWithAuth( - clusterDetails: ClusterDetails, - authConfig: KubernetesRequestAuth, - ): Promise { - const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( - {}, - clusterDetails, - ); - - const { oidcTokenProvider } = clusterDetails; - - if (!oidcTokenProvider || oidcTokenProvider === '') { - throw new Error( - `oidc authProvider requires a configured oidcTokenProvider`, - ); - } - - const authToken: string | undefined = authConfig.oidc?.[oidcTokenProvider]; - - if (authToken) { - clusterDetailsWithAuthToken.serviceAccountToken = authToken; - } else { - throw new Error( - `Auth token not found under oidc.${oidcTokenProvider} in request body`, - ); - } - return clusterDetailsWithAuthToken; - } -} diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts index b40503efc9..093831164e 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -40,8 +40,7 @@ describe('MultiTenantConfigClusterLocator', () => { { name: 'cluster1', url: 'http://localhost:8080', - authProvider: 'serviceAccount', - serviceAccountToken: '12345', + authMetadata: {}, }, ]; }, @@ -56,9 +55,8 @@ describe('MultiTenantConfigClusterLocator', () => { clusters: [ { name: 'cluster1', - serviceAccountToken: '12345', url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: {}, }, ], }); @@ -70,14 +68,13 @@ describe('MultiTenantConfigClusterLocator', () => { return [ { name: 'cluster1', - serviceAccountToken: 'token', url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: {}, }, { name: 'cluster2', url: 'http://localhost:8081', - authProvider: 'google', + authMetadata: {}, }, ]; }, @@ -92,14 +89,13 @@ describe('MultiTenantConfigClusterLocator', () => { clusters: [ { name: 'cluster1', - serviceAccountToken: 'token', url: 'http://localhost:8080', - authProvider: 'serviceAccount', + authMetadata: {}, }, { name: 'cluster2', url: 'http://localhost:8081', - authProvider: 'google', + authMetadata: {}, }, ], }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index 8eb7a066df..85a4326d60 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -17,7 +17,12 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { Config, ConfigReader } from '@backstage/config'; -import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; +import { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, + ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER, + ObjectsByEntityResponse, + KubernetesRequestAuth, +} from '@backstage/plugin-kubernetes-common'; import express from 'express'; import request from 'supertest'; import { @@ -28,6 +33,7 @@ import { KubernetesServiceLocator, ObjectFetchParams, } from '../types/types'; +import { KubernetesCredential } from '../auth/types'; import { KubernetesBuilder } from './KubernetesBuilder'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; import { CatalogApi } from '@backstage/catalog-client'; @@ -50,7 +56,8 @@ describe('KubernetesBuilder', () => { let catalogApi: CatalogApi; let permissions: jest.Mocked; - beforeAll(async () => { + beforeEach(async () => { + jest.resetAllMocks(); const logger = getVoidLogger(); config = new ConfigReader({ kubernetes: { @@ -62,14 +69,18 @@ describe('KubernetesBuilder', () => { const clusters: ClusterDetails[] = [ { name: 'some-cluster', - authProvider: 'serviceAccount', url: 'https://localhost:1234', - serviceAccountToken: 'someToken', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + }, }, { name: 'some-other-cluster', url: 'https://localhost:1235', - authProvider: 'google', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'oidc', + [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google', + }, }, ]; const clusterSupplier: KubernetesClustersSupplier = { @@ -100,10 +111,6 @@ describe('KubernetesBuilder', () => { app = express().use(router); }); - beforeEach(() => { - jest.resetAllMocks(); - }); - describe('get /clusters', () => { it('happy path: lists clusters', async () => { const response = await request(app).get('/clusters'); @@ -117,7 +124,8 @@ describe('KubernetesBuilder', () => { }, { name: 'some-other-cluster', - authProvider: 'google', + authProvider: 'oidc', + oidcTokenProvider: 'google', }, ], }); @@ -190,16 +198,18 @@ describe('KubernetesBuilder', () => { const logger = getVoidLogger(); const someCluster: ClusterDetails = { name: 'some-cluster', - authProvider: 'serviceAccount', url: 'https://localhost:1234', - serviceAccountToken: 'someToken', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + serviceAccountToken: 'placeholder-token', + }, }; const clusters: ClusterDetails[] = [ someCluster, { name: 'some-other-cluster', url: 'https://localhost:1235', - authProvider: 'google', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, }, ]; const clusterSupplier: KubernetesClustersSupplier = { @@ -241,6 +251,7 @@ describe('KubernetesBuilder', () => { const fetcher: KubernetesFetcher = { fetchPodMetricsByNamespaces( _clusterDetails: ClusterDetails, + _credential: KubernetesCredential, _namespaces: Set, ): Promise { return Promise.resolve({ errors: [], responses: [] }); @@ -285,8 +296,77 @@ describe('KubernetesBuilder', () => { expect(response.body).toEqual(result); expect(response.status).toEqual(200); }); + + it('reads auth data for custom strategy', async () => { + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + const mockFetcher = { + fetchPodMetricsByNamespaces: jest + .fn() + .mockResolvedValue({ errors: [], responses: [] }), + fetchObjectsForService: jest.fn().mockResolvedValue({ + errors: [], + responses: [ + { type: 'pods', resources: [{ metadata: { name: 'pod1' } }] }, + ], + }), + }; + const { router } = await KubernetesBuilder.createBuilder({ + logger: getVoidLogger(), + config, + catalogApi, + permissions, + }) + .addAuthStrategy('custom', { + getCredential: jest + .fn< + Promise, + [ClusterDetails, KubernetesRequestAuth] + >() + .mockImplementation(async (_, requestAuth) => ({ + type: 'bearer token', + token: requestAuth.custom as string, + })), + validateCluster: jest.fn().mockReturnValue([]), + }) + .setClusterSupplier({ + getClusters: jest + .fn, []>() + .mockResolvedValue([ + { + name: 'custom-cluster', + url: 'http://my.cluster.url', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'custom', + }, + }, + ]), + }) + .setFetcher(mockFetcher) + .build(); + app = express().use(router); + + await request(app) + .post('/services/test-service') + .send({ + entity: { + metadata: { + name: 'thing', + }, + }, + auth: { custom: 'custom-token' }, + }); + + expect(mockFetcher.fetchObjectsForService).toHaveBeenCalledWith( + expect.objectContaining({ + credential: { type: 'bearer token', token: 'custom-token' }, + }), + ); + }); }); - describe('post /proxy', () => { + + describe('/proxy', () => { const worker = setupServer(); setupRequestMockHandlers(worker); @@ -387,6 +467,59 @@ metadata: expect(response.status).toEqual(403); }); + + it('permits custom client-side auth strategy', async () => { + worker.use( + rest.get('http://my.cluster.url/api/v1/namespaces', (req, res, ctx) => { + if (req.headers.get('Authorization') !== 'custom-token') { + return res(ctx.status(401)); + } + return res(ctx.json({ items: [] })); + }), + ); + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + const { router } = await KubernetesBuilder.createBuilder({ + logger: getVoidLogger(), + config, + catalogApi, + permissions, + }) + .addAuthStrategy('custom', { + getCredential: jest + .fn< + Promise, + [ClusterDetails, KubernetesRequestAuth] + >() + .mockResolvedValue({ type: 'anonymous' }), + validateCluster: jest.fn().mockReturnValue([]), + }) + .setClusterSupplier({ + getClusters: jest + .fn, []>() + .mockResolvedValue([ + { + name: 'custom-cluster', + url: 'http://my.cluster.url', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'custom', + }, + }, + ]), + }) + .build(); + app = express().use(router); + + const proxyEndpointRequest = request(app) + .get('/proxy/api/v1/namespaces') + .set(HEADER_KUBERNETES_CLUSTER, 'custom-cluster') + .set(HEADER_KUBERNETES_AUTH, 'custom-token'); + worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough())); + const response = await proxyEndpointRequest; + + expect(response.body).toStrictEqual({ items: [] }); + }); }); describe('get /.well-known/backstage/permissions/metadata', () => { it('lists permissions supported by the kubernetes plugin', async () => { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 8f9d0769ed..f3766d8513 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -15,7 +15,11 @@ */ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; -import { kubernetesPermissions } from '@backstage/plugin-kubernetes-common'; +import { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, + ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER, + kubernetesPermissions, +} from '@backstage/plugin-kubernetes-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import express from 'express'; @@ -25,16 +29,17 @@ import { Logger } from 'winston'; import { getCombinedClusterSupplier } from '../cluster-locator'; import { - KubernetesAuthTranslator, - DispatchingKubernetesAuthTranslator, - GoogleKubernetesAuthTranslator, - NoopKubernetesAuthTranslator, - AwsIamKubernetesAuthTranslator, - GoogleServiceAccountAuthTranslator, - AzureIdentityKubernetesAuthTranslator, - OidcKubernetesAuthTranslator, - AksKubernetesAuthTranslator, -} from '../kubernetes-auth-translator'; + AuthenticationStrategy, + AnonymousStrategy, + DispatchStrategy, + GoogleStrategy, + ServiceAccountStrategy, + AwsIamStrategy, + GoogleServiceAccountStrategy, + AzureIdentityStrategy, + OidcStrategy, + AksStrategy, +} from '../auth'; import { addResourceRoutesToRouter } from '../routes/resourcesRoutes'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; @@ -80,7 +85,7 @@ export type KubernetesBuilderReturn = Promise<{ proxy: KubernetesProxy; objectsProvider: KubernetesObjectsProvider; serviceLocator: KubernetesServiceLocator; - authTranslatorMap: { [key: string]: KubernetesAuthTranslator }; + authStrategyMap: { [key: string]: AuthenticationStrategy }; }>; /** @@ -96,7 +101,7 @@ export class KubernetesBuilder { private fetcher?: KubernetesFetcher; private serviceLocator?: KubernetesServiceLocator; private proxy?: KubernetesProxy; - private authTranslatorMap?: { [key: string]: KubernetesAuthTranslator }; + private authStrategyMap?: { [key: string]: AuthenticationStrategy }; static createBuilder(env: KubernetesEnvironment) { return new KubernetesBuilder(env); @@ -128,7 +133,7 @@ export class KubernetesBuilder { const clusterSupplier = this.getClusterSupplier(); - const authTranslatorMap = this.getAuthTranslatorMap(); + const authStrategyMap = this.getAuthStrategyMap(); const proxy = this.getProxy(logger, clusterSupplier); @@ -159,7 +164,7 @@ export class KubernetesBuilder { objectsProvider, router, serviceLocator, - authTranslatorMap, + authStrategyMap, }; } @@ -193,10 +198,15 @@ export class KubernetesBuilder { return this; } - public setAuthTranslatorMap(authTranslatorMap: { - [key: string]: KubernetesAuthTranslator; + public setAuthStrategyMap(authStrategyMap: { + [key: string]: AuthenticationStrategy; }) { - this.authTranslatorMap = authTranslatorMap; + this.authStrategyMap = authStrategyMap; + } + + public addAuthStrategy(key: string, strategy: AuthenticationStrategy) { + this.getAuthStrategyMap()[key] = strategy; + return this; } protected buildCustomResources() { @@ -225,6 +235,7 @@ export class KubernetesBuilder { this.clusterSupplier = getCombinedClusterSupplier( config, this.env.catalogApi, + new DispatchStrategy({ authStrategyMap: this.getAuthStrategyMap() }), refreshInterval, ); @@ -234,11 +245,11 @@ export class KubernetesBuilder { protected buildObjectsProvider( options: KubernetesObjectsProviderOptions, ): KubernetesObjectsProvider { - const authTranslatorMap = this.getAuthTranslatorMap(); + const authStrategyMap = this.getAuthStrategyMap(); this.objectsProvider = new KubernetesFanOutHandler({ ...options, - authTranslator: new DispatchingKubernetesAuthTranslator({ - authTranslatorMap, + authStrategy: new DispatchStrategy({ + authStrategyMap, }), }); @@ -290,14 +301,14 @@ export class KubernetesBuilder { logger: Logger, clusterSupplier: KubernetesClustersSupplier, ): KubernetesProxy { - const authTranslatorMap = this.getAuthTranslatorMap(); - const authTranslator = new DispatchingKubernetesAuthTranslator({ - authTranslatorMap, + const authStrategyMap = this.getAuthStrategyMap(); + const authStrategy = new DispatchStrategy({ + authStrategyMap, }); this.proxy = new KubernetesProxy({ logger, clusterSupplier, - authTranslator, + authStrategy, }); return this.proxy; } @@ -339,12 +350,16 @@ export class KubernetesBuilder { router.get('/clusters', async (_, res) => { const clusterDetails = await this.fetchClusterDetails(clusterSupplier); res.json({ - items: clusterDetails.map(cd => ({ - name: cd.name, - dashboardUrl: cd.dashboardUrl, - authProvider: cd.authProvider, - oidcTokenProvider: cd.oidcTokenProvider, - })), + items: clusterDetails.map(cd => { + const oidcTokenProvider = + cd.authMetadata[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]; + return { + name: cd.name, + dashboardUrl: cd.dashboardUrl, + authProvider: cd.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER], + ...(oidcTokenProvider && { oidcTokenProvider }), + }; + }), }); }); @@ -353,18 +368,18 @@ export class KubernetesBuilder { return router; } - protected buildAuthTranslatorMap() { - this.authTranslatorMap = { - google: new GoogleKubernetesAuthTranslator(), - aks: new AksKubernetesAuthTranslator(), - aws: new AwsIamKubernetesAuthTranslator({ config: this.env.config }), - azure: new AzureIdentityKubernetesAuthTranslator(this.env.logger), - serviceAccount: new NoopKubernetesAuthTranslator(), - googleServiceAccount: new GoogleServiceAccountAuthTranslator(), - oidc: new OidcKubernetesAuthTranslator(), - localKubectlProxy: new NoopKubernetesAuthTranslator(), + protected buildAuthStrategyMap() { + this.authStrategyMap = { + aks: new AksStrategy(), + aws: new AwsIamStrategy({ config: this.env.config }), + azure: new AzureIdentityStrategy(this.env.logger), + google: new GoogleStrategy(), + googleServiceAccount: new GoogleServiceAccountStrategy(), + localKubectlProxy: new AnonymousStrategy(), + oidc: new OidcStrategy(), + serviceAccount: new ServiceAccountStrategy(), }; - return this.authTranslatorMap; + return this.authStrategyMap; } protected async fetchClusterDetails( @@ -447,7 +462,7 @@ export class KubernetesBuilder { return this.proxy ?? this.buildProxy(logger, clusterSupplier); } - protected getAuthTranslatorMap() { - return this.authTranslatorMap ?? this.buildAuthTranslatorMap(); + protected getAuthStrategyMap() { + return this.authStrategyMap ?? this.buildAuthStrategyMap(); } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 5dd74f6daa..1faf2f0673 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -20,19 +20,28 @@ import { CustomResource, ObjectFetchParams, KubernetesServiceLocator, + ServiceLocatorRequestContext, } from '../types/types'; +import { KubernetesCredential } from '../auth/types'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; +import { + KubernetesRequestAuth, + ObjectsByEntityResponse, +} from '@backstage/plugin-kubernetes-common'; import { Config, ConfigReader } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; describe('KubernetesFanOutHandler', () => { const fetchObjectsForService = jest.fn(); const fetchPodMetricsByNamespaces = jest.fn(); - const getClustersByEntity = jest.fn(); + const getClustersByEntity = jest.fn< + Promise<{ clusters: ClusterDetails[] }>, + [Entity] + >(); let config: Config; let sut: KubernetesFanOutHandler; @@ -71,7 +80,8 @@ describe('KubernetesFanOutHandler', () => { const cluster1 = { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, customResources: [ { group: 'some-other-crd.example.com', @@ -83,7 +93,8 @@ describe('KubernetesFanOutHandler', () => { const cluster2 = { name: 'cluster-two', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, customResources: [ { group: 'crd-two.example.com', @@ -177,10 +188,14 @@ describe('KubernetesFanOutHandler', () => { getClustersByEntity, }, customResources: customResources, - authTranslator: { - decorateClusterDetailsWithAuth: async (clusterDetails, _) => { - return clusterDetails; - }, + authStrategy: { + getCredential: jest + .fn< + Promise, + [ClusterDetails, KubernetesRequestAuth] + >() + .mockResolvedValue({ type: 'anonymous' }), + validateCluster: jest.fn().mockReturnValue([]), }, config, }); @@ -308,7 +323,11 @@ describe('KubernetesFanOutHandler', () => { ); fetchPodMetricsByNamespaces.mockImplementation( - (_clusterDetails: ClusterDetails, namespaces: Set) => + ( + _clusterDetails: ClusterDetails, + _: KubernetesCredential, + namespaces: Set, + ) => Promise.resolve({ errors: [], responses: Array.from(namespaces).map(() => { @@ -343,7 +362,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, }, ], }), @@ -361,6 +381,7 @@ describe('KubernetesFanOutHandler', () => { expect(fetchPodMetricsByNamespaces).toHaveBeenCalledTimes(1); expect(fetchPodMetricsByNamespaces).toHaveBeenCalledWith( expect.anything(), + { type: 'anonymous' }, new Set(['ns-test-component-test-cluster']), expect.anything(), ); @@ -457,7 +478,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, }, cluster2, ], @@ -511,7 +533,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'profile-cluster-1', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, customResourceProfile: 'build', customResources: [ { @@ -559,7 +582,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'profile-cluster-1', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, }, ], }), @@ -599,7 +623,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, }, ], }), @@ -648,6 +673,7 @@ describe('KubernetesFanOutHandler', () => { expect(fetchPodMetricsByNamespaces).toHaveBeenCalledTimes(1); expect(fetchPodMetricsByNamespaces).toHaveBeenCalledWith( expect.anything(), + { type: 'anonymous' }, new Set(['ns-a', 'ns-b']), expect.anything(), ); @@ -696,7 +722,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, }, ], }), @@ -735,12 +762,14 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, dashboardUrl: 'https://k8s.foo.coom', }, { name: 'other-cluster', - authProvider: 'google', + url: '', + authMetadata: {}, }, ], }), @@ -786,15 +815,18 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, }, { name: 'other-cluster', - authProvider: 'google', + url: '', + authMetadata: {}, }, { name: 'empty-cluster', - authProvider: 'google', + url: '', + authMetadata: {}, }, ], }), @@ -839,19 +871,23 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, }, { name: 'other-cluster', - authProvider: 'google', + url: '', + authMetadata: {}, }, { name: 'empty-cluster', - authProvider: 'google', + url: '', + authMetadata: {}, }, { name: 'error-cluster', - authProvider: 'google', + url: '', + authMetadata: {}, }, ], }), @@ -917,12 +953,14 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, dashboardUrl: 'https://k8s.foo.coom', }, { name: 'other-cluster', - authProvider: 'google', + url: '', + authMetadata: {}, }, ], }), @@ -932,7 +970,11 @@ describe('KubernetesFanOutHandler', () => { // and an error for the second call. fetchPodMetricsByNamespaces .mockImplementationOnce( - (_clusterDetails: ClusterDetails, namespaces: Set) => + ( + _clusterDetails: ClusterDetails, + _: KubernetesCredential, + namespaces: Set, + ) => Promise.resolve({ errors: [], responses: Array.from(namespaces).map(() => { @@ -1015,7 +1057,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, skipMetricsLookup: true, }, ], @@ -1054,24 +1097,27 @@ describe('KubernetesFanOutHandler', () => { ); const fleet: jest.Mocked = { - getClustersByEntity: jest.fn().mockResolvedValue({ - clusters: [ - { - name: 'works', - url: 'https://works', - authProvider: 'serviceAccount', - serviceAccountToken: 'token', - skipMetricsLookup: true, - }, - { - name: 'fails', - url: 'https://fails', - authProvider: 'serviceAccount', - serviceAccountToken: 'token', - skipMetricsLookup: true, - }, - ], - }), + getClustersByEntity: jest + .fn< + Promise<{ clusters: ClusterDetails[] }>, + [Entity, ServiceLocatorRequestContext] + >() + .mockResolvedValue({ + clusters: [ + { + name: 'works', + url: 'https://works', + authMetadata: {}, + skipMetricsLookup: true, + }, + { + name: 'fails', + url: 'https://fails', + authMetadata: {}, + skipMetricsLookup: true, + }, + ], + }), }; const logger = getVoidLogger(); const kubernetesFanOutHandler = new KubernetesFanOutHandler({ @@ -1093,10 +1139,14 @@ describe('KubernetesFanOutHandler', () => { objectType: 'services', }, ], - authTranslator: { - decorateClusterDetailsWithAuth: async (clusterDetails, _) => { - return clusterDetails; - }, + authStrategy: { + getCredential: jest + .fn< + Promise, + [ClusterDetails, KubernetesRequestAuth] + >() + .mockResolvedValue({ type: 'bearer token', token: 'token' }), + validateCluster: jest.fn().mockReturnValue([]), }, config, }); @@ -1192,7 +1242,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'test-cluster', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, }, cluster2, ], @@ -1265,7 +1316,8 @@ describe('KubernetesFanOutHandler', () => { clusters: [ { name: 'profile-cluster-1', - authProvider: 'serviceAccount', + url: '', + authMetadata: {}, customResourceProfile: 'build', }, ], diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 79003ae1ca..f2f1c6d38f 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -27,9 +27,8 @@ import { CustomResource, CustomResourcesByEntity, KubernetesObjectsByEntity, - ServiceLocatorRequestContext, } from '../types/types'; -import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; +import { AuthenticationStrategy, KubernetesCredential } from '../auth/types'; import { ClientContainerStatus, ClientCurrentResourceUsage, @@ -48,14 +47,6 @@ import { PodStatus, } from '@kubernetes/client-node'; -const isRejected = ( - input: PromiseSettledResult, -): input is PromiseRejectedResult => input.status === 'rejected'; - -const isFulfilled = ( - input: PromiseSettledResult, -): input is PromiseFulfilledResult => input.status === 'fulfilled'; - /** * * @public @@ -137,7 +128,7 @@ export const DEFAULT_OBJECTS: ObjectToFetch[] = [ export interface KubernetesFanOutHandlerOptions extends KubernetesObjectsProviderOptions { - authTranslator: KubernetesAuthTranslator; + authStrategy: AuthenticationStrategy; } export interface KubernetesRequestBody extends ObjectsByEntityRequest {} @@ -196,7 +187,7 @@ export class KubernetesFanOutHandler { private readonly serviceLocator: KubernetesServiceLocator; private readonly customResources: CustomResource[]; private readonly objectTypesToFetch: Set; - private readonly authTranslator: KubernetesAuthTranslator; + private readonly authStrategy: AuthenticationStrategy; constructor({ logger, @@ -204,14 +195,14 @@ export class KubernetesFanOutHandler { serviceLocator, customResources, objectTypesToFetch = DEFAULT_OBJECTS, - authTranslator, + authStrategy, }: KubernetesFanOutHandlerOptions) { this.logger = logger; this.fetcher = fetcher; this.serviceLocator = serviceLocator; this.customResources = customResources; this.objectTypesToFetch = new Set(objectTypesToFetch); - this.authTranslator = authTranslator; + this.authStrategy = authStrategy; } async getCustomResourcesByEntity({ @@ -245,14 +236,13 @@ export class KubernetesFanOutHandler { entity.metadata?.annotations?.['backstage.io/kubernetes-id'] || entity.metadata?.name; - const clusterDetailsDecoratedForAuth: ClusterDetails[] = - await this.decorateClusterDetailsWithAuth(entity, auth, { - objectTypesToFetch: objectTypesToFetch, - customResources: customResources ?? [], - }); + const { clusters } = await this.serviceLocator.getClustersByEntity(entity, { + objectTypesToFetch: objectTypesToFetch, + customResources: customResources ?? [], + }); this.logger.info( - `entity.metadata.name=${entityName} clusterDetails=[${clusterDetailsDecoratedForAuth + `entity.metadata.name=${entityName} clusterDetails=[${clusters .map(c => c.name) .join(', ')}]`, ); @@ -266,16 +256,21 @@ export class KubernetesFanOutHandler { entity.metadata?.annotations?.['backstage.io/kubernetes-namespace']; return Promise.all( - clusterDetailsDecoratedForAuth.map(clusterDetailsItem => { + clusters.map(async clusterDetails => { + const credential = await this.authStrategy.getCredential( + clusterDetails, + auth, + ); return this.fetcher .fetchObjectsForService({ serviceId: entityName, - clusterDetails: clusterDetailsItem, - objectTypesToFetch: objectTypesToFetch, + clusterDetails, + credential, + objectTypesToFetch, labelSelector, customResources: ( customResources || - clusterDetailsItem.customResources || + clusterDetails.customResources || this.customResources ).map(c => ({ ...c, @@ -284,7 +279,12 @@ export class KubernetesFanOutHandler { namespace, }) .then(result => - this.getMetricsForPods(clusterDetailsItem, labelSelector, result), + this.getMetricsForPods( + clusterDetails, + credential, + labelSelector, + result, + ), ) .catch( (e): Promise => @@ -300,33 +300,11 @@ export class KubernetesFanOutHandler { ]) : Promise.reject(e), ) - .then(r => this.toClusterObjects(clusterDetailsItem, r)); + .then(r => this.toClusterObjects(clusterDetails, r)); }), ).then(this.toObjectsByEntityResponse); } - private async decorateClusterDetailsWithAuth( - entity: Entity, - auth: KubernetesRequestAuth, - requestContext: ServiceLocatorRequestContext, - ) { - const clusterDetails: ClusterDetails[] = ( - await this.serviceLocator.getClustersByEntity(entity, requestContext) - ).clusters; - - // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them - const promiseResults = await Promise.allSettled( - clusterDetails.map(cd => { - return this.authTranslator.decorateClusterDetailsWithAuth(cd, auth); - }), - ); - - promiseResults.filter(isRejected).map(item => { - this.logger.info(`Failed to decorate cluster details: ${item.reason}`); - }); - return promiseResults.filter(isFulfilled).map(item => item.value); - } - toObjectsByEntityResponse( clusterObjects: ClusterObjects[], ): ObjectsByEntityResponse { @@ -367,6 +345,7 @@ export class KubernetesFanOutHandler { async getMetricsForPods( clusterDetails: ClusterDetails, + credential: KubernetesCredential, labelSelector: string, result: FetchResponseWrapper, ): Promise { @@ -387,6 +366,7 @@ export class KubernetesFanOutHandler { const podMetrics = await this.fetcher.fetchPodMetricsByNamespaces( clusterDetails, + credential, namespaces, labelSelector, ); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index 1a603a7a98..2cecbbeaec 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common'; import { getVoidLogger } from '@backstage/backend-common'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { ObjectToFetch } from '../types/types'; @@ -138,9 +139,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: '', customResources: [], @@ -199,9 +200,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://localhost:9999/k8s/clusters/1234', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: '', customResources: [], @@ -253,8 +254,11 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://localhost:9999/k8s/clusters/1234', - authProvider: 'localKubectlProxy', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'localKubectlProxy', + }, }, + credential: { type: 'anonymous' }, objectTypesToFetch: new Set([ { group: '', @@ -309,9 +313,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: '', customResources: [], @@ -383,9 +387,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: '', customResources: [ @@ -475,9 +479,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: '', customResources: [], @@ -563,9 +567,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://badurl.does.not.exist', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: '', customResources: [], @@ -600,9 +604,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: 'service-label=value', customResources: [], @@ -667,10 +671,10 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, caData: 'MOCKCA', }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: new Set([ { group: '', @@ -704,9 +708,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: new Set([ { group: '', @@ -747,10 +751,10 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, caFile: '/path/to/ca.crt', }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: new Set([ { group: '', @@ -785,10 +789,10 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, skipTLSVerify: true, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: new Set([ { group: '', @@ -836,9 +840,9 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'bearer token', token: 'token' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: '', namespace: 'some-namespace', @@ -874,14 +878,15 @@ describe('KubernetesFetcher', () => { }); }); describe('Backstage not running on k8s', () => { - it('fails if cluster details has no token', () => { + it('fails if no credential is provided', () => { const result = sut.fetchObjectsForService({ serviceId: 'some-service', clusterDetails: { name: 'unauthenticated-cluster', url: 'http://ignored', - authProvider: 'serviceAccount', + authMetadata: {}, }, + credential: { type: 'anonymous' }, objectTypesToFetch: OBJECTS_TO_FETCH, labelSelector: '', customResources: [], @@ -904,8 +909,6 @@ describe('KubernetesFetcher', () => { process.env.KUBERNETES_SERVICE_PORT = '443'; mockFs({ '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt': '', - '/var/run/secrets/kubernetes.io/serviceaccount/token': - 'allowed-token', }); worker.use( rest.get('https://10.10.10.10/api/v1/pods', (req, res, ctx) => @@ -923,8 +926,11 @@ describe('KubernetesFetcher', () => { clusterDetails: { name: 'overridden-to-in-cluster', url: 'http://ignored', - authProvider: 'serviceAccount', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount', + }, }, + credential: { type: 'bearer token', token: 'allowed-token' }, objectTypesToFetch: new Set([ { group: '', @@ -1019,9 +1025,9 @@ describe('KubernetesFetcher', () => { { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + { type: 'bearer token', token: 'token' }, new Set(['ns-a']), ); expect(result).toMatchObject({ @@ -1106,9 +1112,9 @@ describe('KubernetesFetcher', () => { { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', + authMetadata: {}, }, + { type: 'bearer token', token: 'token' }, new Set(['ns-a', 'ns-b']), ); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index 25bade3e97..e514f5fe0b 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -20,7 +20,6 @@ import { CoreV1Api, KubeConfig, Metrics, - User, bufferFromFileOrString, topPods, } from '@kubernetes/client-node'; @@ -32,7 +31,9 @@ import { KubernetesFetcher, ObjectFetchParams, } from '../types/types'; +import { KubernetesCredential } from '../auth/types'; import { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, FetchResponse, KubernetesFetchError, KubernetesErrorTypes, @@ -95,6 +96,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { .map(({ objectType, group, apiVersion, plural }) => this.fetchResource( params.clusterDetails, + params.credential, group, apiVersion, plural, @@ -125,6 +127,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { fetchPodMetricsByNamespaces( clusterDetails: ClusterDetails, + credential: KubernetesCredential, namespaces: Set, labelSelector?: string, ): Promise { @@ -132,13 +135,22 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { const [podMetrics, podList] = await Promise.all([ this.fetchResource( clusterDetails, + credential, 'metrics.k8s.io', 'v1beta1', 'pods', ns, labelSelector, ), - this.fetchResource(clusterDetails, '', 'v1', 'pods', ns, labelSelector), + this.fetchResource( + clusterDetails, + credential, + '', + 'v1', + 'pods', + ns, + labelSelector, + ), ]); if (podMetrics.ok && podList.ok) { return topPods( @@ -183,6 +195,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { private fetchResource( clusterDetails: ClusterDetails, + credential: KubernetesCredential, group: string, apiVersion: string, plural: string, @@ -200,13 +213,19 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { let url: URL; let requestInit: RequestInit; + const authProvider = + clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER]; if ( - clusterDetails.serviceAccountToken || - clusterDetails.authProvider === 'localKubectlProxy' + authProvider === 'serviceAccount' && + !clusterDetails.authMetadata.serviceAccountToken && + fs.pathExistsSync(Config.SERVICEACCOUNT_CA_PATH) ) { - [url, requestInit] = this.fetchArgsFromClusterDetails(clusterDetails); - } else if (fs.pathExistsSync(Config.SERVICEACCOUNT_TOKEN_PATH)) { - [url, requestInit] = this.fetchArgsInCluster(); + [url, requestInit] = this.fetchArgsInCluster(credential); + } else if ( + credential.type === 'bearer token' || + authProvider === 'localKubectlProxy' + ) { + [url, requestInit] = this.fetchArgs(clusterDetails, credential); } else { return Promise.reject( new Error( @@ -228,15 +247,18 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { return fetch(url, requestInit); } - private fetchArgsFromClusterDetails( + private fetchArgs( clusterDetails: ClusterDetails, + credential: KubernetesCredential, ): [URL, RequestInit] { const requestInit: RequestInit = { method: 'GET', headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: `Bearer ${clusterDetails.serviceAccountToken}`, + ...(credential.type === 'bearer token' && { + Authorization: `Bearer ${credential.token}`, + }), }, }; @@ -253,24 +275,25 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { } return [url, requestInit]; } - private fetchArgsInCluster(): [URL, RequestInit] { - const kc = new KubeConfig(); - kc.loadFromCluster(); - // loadFromCluster is guaranteed to populate the cluster/user/context - const cluster = kc.getCurrentCluster() as Cluster; - const user = kc.getCurrentUser() as User; - - const token = fs.readFileSync(user.authProvider.config.tokenFile); - + private fetchArgsInCluster( + credential: KubernetesCredential, + ): [URL, RequestInit] { const requestInit: RequestInit = { method: 'GET', headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + ...(credential.type === 'bearer token' && { + Authorization: `Bearer ${credential.token}`, + }), }, }; + const kc = new KubeConfig(); + kc.loadFromCluster(); + // loadFromCluster is guaranteed to populate the cluster/user/context + const cluster = kc.getCurrentCluster() as Cluster; + const url = new URL(cluster.server); if (url.protocol === 'https:') { requestInit.agent = new https.Agent({ diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index 6c8953d0a3..a84482082f 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -23,6 +23,7 @@ import { AuthorizeResult, PermissionEvaluator, } from '@backstage/plugin-permission-common'; +import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; import { getMockReq, getMockRes } from '@jest-mock/express'; import express from 'express'; import Router from 'express-promise-router'; @@ -34,9 +35,10 @@ import { AddressInfo, WebSocket, WebSocketServer } from 'ws'; import { LocalKubectlProxyClusterLocator } from '../cluster-locator/LocalKubectlProxyLocator'; import { - KubernetesAuthTranslator, - NoopKubernetesAuthTranslator, -} from '../kubernetes-auth-translator'; + AuthenticationStrategy, + AnonymousStrategy, + KubernetesCredential, +} from '../auth'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { APPLICATION_JSON, @@ -50,11 +52,12 @@ import type { Request } from 'express'; describe('KubernetesProxy', () => { let proxy: KubernetesProxy; + let authStrategy: jest.Mocked; const worker = setupServer(); const logger = getVoidLogger(); const clusterSupplier: jest.Mocked = { - getClusters: jest.fn(), + getClusters: jest.fn, []>(), }; const permissionApi: jest.Mocked = { @@ -62,10 +65,6 @@ describe('KubernetesProxy', () => { authorizeConditional: jest.fn(), }; - const authTranslator: jest.Mocked = { - decorateClusterDetailsWithAuth: jest.fn(), - }; - setupRequestMockHandlers(worker); const buildMockRequest = (clusterName: any, path: string): Request => { @@ -126,7 +125,16 @@ describe('KubernetesProxy', () => { beforeEach(() => { jest.resetAllMocks(); - proxy = new KubernetesProxy({ logger, clusterSupplier, authTranslator }); + authStrategy = { + getCredential: jest + .fn< + Promise, + [ClusterDetails, KubernetesRequestAuth] + >() + .mockResolvedValue({ type: 'anonymous' }), + validateCluster: jest.fn(), + }; + proxy = new KubernetesProxy({ logger, clusterSupplier, authStrategy }); permissionApi.authorize.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); @@ -148,15 +156,14 @@ describe('KubernetesProxy', () => { { name: 'local', url: 'http:/localhost:8001', - authProvider: 'localKubectlProxy', + authMetadata: {}, skipMetricsLookup: true, - } as ClusterDetails, + }, { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: 'tokenA', - authProvider: 'googleServiceAccount', - } as ClusterDetails, + authMetadata: {}, + }, ]); const req = buildMockRequest(undefined, 'api'); @@ -172,9 +179,8 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: 'tokenA', - authProvider: 'googleServiceAccount', - } as ClusterDetails, + authMetadata: {}, + }, ]); const req = buildMockRequest('test', 'api'); @@ -201,17 +207,9 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: '', - authProvider: 'serviceAccount', + authMetadata: {}, }, - ] as ClusterDetails[]); - - authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ - name: 'cluster1', - url: 'https://localhost:9999', - serviceAccountToken: '', - authProvider: 'serviceAccount', - } as ClusterDetails); + ]); worker.use( rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) => @@ -247,17 +245,9 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: '', - authProvider: 'serviceAccount', + authMetadata: {}, }, - ] as ClusterDetails[]); - - authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ - name: 'cluster1', - url: 'https://localhost:9999', - serviceAccountToken: '', - authProvider: 'serviceAccount', - } as ClusterDetails); + ]); worker.use( rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) => @@ -293,12 +283,9 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'http://localhost:9999', - authProvider: '', + authMetadata: {}, }, ]); - authTranslator.decorateClusterDetailsWithAuth.mockImplementation( - async x => x, - ); const requestPromise = setupProxyPromise({ proxyPath: '/mountpath', @@ -312,7 +299,7 @@ describe('KubernetesProxy', () => { expect(response.status).toEqual(200); }); - it('should default to using a authTranslator provided serviceAccountToken as authorization headers to kubeapi when backstage-kubernetes-auth field is not provided', async () => { + it('should default to using a strategy-provided bearer token as authorization headers to kubeapi when backstage-kubernetes-auth field is not provided', async () => { worker.use( rest.get( 'https://localhost:9999/api/v1/namespaces', @@ -323,7 +310,7 @@ describe('KubernetesProxy', () => { if ( req.headers.get('Authorization') !== - 'Bearer translator-provided-token' + 'Bearer strategy-provided-token' ) { return res(ctx.status(403)); } @@ -344,17 +331,14 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: '', - authProvider: 'serviceAccount', + authMetadata: {}, }, - ] as ClusterDetails[]); + ]); - authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ - name: 'cluster1', - url: 'https://localhost:9999', - serviceAccountToken: 'translator-provided-token', - authProvider: 'serviceAccount', - } as ClusterDetails); + authStrategy.getCredential.mockResolvedValue({ + type: 'bearer token', + token: 'strategy-provided-token', + }); const requestPromise = setupProxyPromise({ proxyPath: '/mountpath', @@ -368,7 +352,7 @@ describe('KubernetesProxy', () => { expect(response.status).toEqual(200); }); - it('should add a authTranslator provided serviceAccountToken as authorization headers to kubeapi if one isnt provided in request and one isnt set up in cluster details', async () => { + it('should add an authStrategy-provided serviceAccountToken as authorization headers to kubeapi if one isnt provided in request and one isnt set up in cluster details', async () => { worker.use( rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => { if (!req.headers.get('Authorization')) { @@ -394,16 +378,14 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - authProvider: 'googleServiceAccount', + authMetadata: {}, }, - ] as ClusterDetails[]); + ]); - authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ - name: 'cluster1', - url: 'https://localhost:9999', - serviceAccountToken: 'my-token', - authProvider: 'googleServiceAccount', - } as ClusterDetails); + authStrategy.getCredential.mockResolvedValue({ + type: 'bearer token', + token: 'my-token', + }); const requestPromise = setupProxyPromise({ proxyPath: '/mountpath', @@ -448,16 +430,14 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - authProvider: 'googleServiceAccount', + authMetadata: {}, }, - ] as ClusterDetails[]); + ]); - authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ - name: 'cluster1', - url: 'https://localhost:9999', - serviceAccountToken: 'tokenA', - authProvider: 'googleServiceAccount', - } as ClusterDetails); + authStrategy.getCredential.mockResolvedValue({ + type: 'bearer token', + token: 'tokenA', + }); const requestPromise = setupProxyPromise({ proxyPath: '/mountpath', @@ -479,7 +459,7 @@ describe('KubernetesProxy', () => { }); }); - it('should not invoke authTranslator if Backstage-Kubernetes-Authorization field is provided', async () => { + it('should not invoke authStrategy if Backstage-Kubernetes-Authorization field is provided', async () => { worker.use( rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => { if (!req.headers.get('Authorization')) { @@ -505,9 +485,9 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - authProvider: 'googleServiceAccount', + authMetadata: {}, }, - ] as ClusterDetails[]); + ]); const requestPromise = setupProxyPromise({ proxyPath: '/mountpath', @@ -521,9 +501,7 @@ describe('KubernetesProxy', () => { const response = await requestPromise; - expect(authTranslator.decorateClusterDetailsWithAuth).toHaveBeenCalledTimes( - 0, - ); + expect(authStrategy.getCredential).toHaveBeenCalledTimes(0); expect(response.status).toEqual(200); expect(response.body).toStrictEqual({ kind: 'NamespaceList', @@ -536,7 +514,7 @@ describe('KubernetesProxy', () => { proxy = new KubernetesProxy({ logger: getVoidLogger(), clusterSupplier: new LocalKubectlProxyClusterLocator(), - authTranslator: new NoopKubernetesAuthTranslator(), + authStrategy: new AnonymousStrategy(), }); worker.use( @@ -573,7 +551,7 @@ describe('KubernetesProxy', () => { }); }); - it('returns a 500 error if authTranslator errors out and Backstage-Kubernetes-Authorization field is not provided', async () => { + it('returns a 500 error if authStrategy errors out and Backstage-Kubernetes-Authorization field is not provided', async () => { worker.use( rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => { if (!req.headers.get('Authorization')) { @@ -599,14 +577,11 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - authProvider: 'google', - serviceAccountToken: 'client-side-token', + authMetadata: {}, }, - ] as ClusterDetails[]); + ]); - authTranslator.decorateClusterDetailsWithAuth.mockRejectedValue( - Error('some internal error'), - ); + authStrategy.getCredential.mockRejectedValue(Error('some internal error')); const requestPromise = setupProxyPromise({ proxyPath: '/mountpath', @@ -643,14 +618,10 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'http://localhost:9999/subpath', - authProvider: '', + authMetadata: {}, }, ]); - authTranslator.decorateClusterDetailsWithAuth.mockImplementation( - async x => x, - ); - const requestPromise = setupProxyPromise({ proxyPath: '/mountpath', requestPath: '/api/v1/namespaces', @@ -696,19 +667,11 @@ describe('KubernetesProxy', () => { { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: '', - authProvider: 'serviceAccount', + authMetadata: {}, caFile: resolvePath(__dirname, '__fixtures__/mock-ca.crt'), }, ] as ClusterDetails[]); - authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ - name: 'cluster1', - url: 'https://localhost:9999', - serviceAccountToken: '', - authProvider: 'serviceAccount', - } as ClusterDetails); - worker.use( rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) => res(ctx.status(299), ctx.json(apiResponse)), @@ -747,7 +710,7 @@ describe('KubernetesProxy', () => { event: 'connection' | 'open' | 'close' | 'error' | 'message', ) => new Promise(resolve => ws.once(event, x => resolve(x?.toString()))); - beforeAll(async () => { + beforeEach(async () => { await new Promise(resolve => { expressServer = express() .use( @@ -778,7 +741,7 @@ describe('KubernetesProxy', () => { wsEchoServer.on('error', console.error); }); - afterAll(() => { + afterEach(() => { wsEchoServer.close(); expressServer.close(); }); @@ -788,17 +751,9 @@ describe('KubernetesProxy', () => { { name: 'local', url: `http://localhost:${wsPort}`, - serviceAccountToken: '', - authProvider: 'serviceAccount', + authMetadata: {}, }, - ] as ClusterDetails[]); - - authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ - name: 'local', - url: `http://localhost:${wsPort}`, - serviceAccountToken: '', - authProvider: 'serviceAccount', - } as ClusterDetails); + ]); const wsProxyAddress = `ws://127.0.0.1:${proxyPort}${proxyPath}${wsPath}`; const wsAddress = `ws://localhost:${wsPort}${wsPath}`; diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 8ae1fb9fcc..6e699d1220 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -31,7 +31,7 @@ import { bufferFromFileOrString } from '@kubernetes/client-node'; import type { Request, RequestHandler } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { Logger } from 'winston'; -import { KubernetesAuthTranslator } from '../kubernetes-auth-translator'; +import { AuthenticationStrategy } from '../auth'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; export const APPLICATION_JSON: string = 'application/json'; @@ -68,7 +68,7 @@ export type KubernetesProxyCreateRequestHandlerOptions = { export type KubernetesProxyOptions = { logger: Logger; clusterSupplier: KubernetesClustersSupplier; - authTranslator: KubernetesAuthTranslator; + authStrategy: AuthenticationStrategy; }; /** @@ -80,12 +80,12 @@ export class KubernetesProxy { private readonly middlewareForClusterName = new Map(); private readonly logger: Logger; private readonly clusterSupplier: KubernetesClustersSupplier; - private readonly authTranslator: KubernetesAuthTranslator; + private readonly authStrategy: AuthenticationStrategy; constructor(options: KubernetesProxyOptions) { this.logger = options.logger; this.clusterSupplier = options.clusterSupplier; - this.authTranslator = options.authTranslator; + this.authStrategy = options.authStrategy; } public createRequestHandler( @@ -112,13 +112,11 @@ export class KubernetesProxy { if (authHeader) { req.headers.authorization = authHeader; } else { - const { serviceAccountToken } = await this.getClusterForRequest( - req, - ).then(cd => - this.authTranslator.decorateClusterDetailsWithAuth(cd, {}), - ); - if (serviceAccountToken) { - req.headers.authorization = `Bearer ${serviceAccountToken}`; + const credential = await this.getClusterForRequest(req).then(cd => { + return this.authStrategy.getCredential(cd, {}); + }); + if (credential.type === 'bearer token') { + req.headers.authorization = `Bearer ${credential.token}`; } } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 15ed1662de..3767791589 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -26,6 +26,7 @@ import type { ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-common'; import { Config } from '@backstage/config'; +import { KubernetesCredential } from '../auth/types'; /** * @@ -33,11 +34,8 @@ import { Config } from '@backstage/config'; */ export interface ObjectFetchParams { serviceId: string; - clusterDetails: - | AWSClusterDetails - | GKEClusterDetails - | ServiceAccountClusterDetails - | ClusterDetails; + clusterDetails: ClusterDetails; + credential: KubernetesCredential; objectTypesToFetch: Set; labelSelector: string; customResources: CustomResource[]; @@ -55,6 +53,7 @@ export interface KubernetesFetcher { ): Promise; fetchPodMetricsByNamespaces( clusterDetails: ClusterDetails, + credential: KubernetesCredential, namespaces: Set, labelSelector?: string, ): Promise; @@ -148,6 +147,12 @@ export interface KubernetesServiceLocator { */ export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http +/** + * Provider-specific authentication configuration + * @public + */ +export type AuthMetadata = Record; + /** * * @public @@ -158,12 +163,7 @@ export interface ClusterDetails { */ name: string; url: string; - authProvider: string; - serviceAccountToken?: string | undefined; - /** - * oidc provider used to get id tokens to authenticate against kubernetes - */ - oidcTokenProvider?: string | undefined; + authMetadata: AuthMetadata; skipTLSVerify?: boolean; /** * Whether to skip the lookup to the metrics server to retrieve pod resource usage. @@ -211,33 +211,6 @@ export interface ClusterDetails { customResources?: CustomResourceMatcher[]; } -/** - * - * @public - */ -export interface GKEClusterDetails extends ClusterDetails {} - -/** - * - * @public - */ -export interface AzureClusterDetails extends ClusterDetails {} - -/** - * - * @public - */ -export interface ServiceAccountClusterDetails extends ClusterDetails {} - -/** - * - * @public - */ -export interface AWSClusterDetails extends ClusterDetails { - assumeRole?: string; - externalId?: string; -} - /** * * @public diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index aae72d6f1e..051293f37f 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -233,16 +233,7 @@ export const kubernetesPermissions: BasicPermission[]; export const kubernetesProxyPermission: BasicPermission; // @public (undocumented) -export interface KubernetesRequestAuth { - // (undocumented) - aks?: string; - // (undocumented) - google?: string; - // (undocumented) - oidc?: { - [key: string]: string; - }; -} +export type KubernetesRequestAuth = JsonObject; // @public (undocumented) export interface KubernetesRequestBody { diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 2b2ab05b51..feb213d520 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -33,13 +33,7 @@ import { import { Entity } from '@backstage/catalog-model'; /** @public */ -export interface KubernetesRequestAuth { - google?: string; - aks?: string; - oidc?: { - [key: string]: string; - }; -} +export type KubernetesRequestAuth = JsonObject; /** @public */ export interface CustomResourceMatcher { diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 7a0c2bf929..4bd55d65a3 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -42,6 +42,7 @@ "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "@backstage/theme": "workspace:^", + "@backstage/types": "workspace:^", "@kubernetes-models/apimachinery": "^1.1.0", "@kubernetes-models/base": "^4.0.1", "@kubernetes/client-node": "0.18.1", diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts index 14e574022a..5d03dcdae3 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts @@ -74,7 +74,7 @@ describe('KubernetesAuthProviders tests', () => { requestBody, ); - expect(details.auth?.oidc?.okta).toBe('oktaToken'); + expect(details).toMatchObject({ auth: { oidc: { okta: 'oktaToken' } } }); }); it('returns error for unknown authProvider', async () => { diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index bd5760bde2..b2e6c5cbc2 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import { KubernetesAuthProvider } from './types'; +import { JsonObject } from '@backstage/types'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { KubernetesAuthProvider } from './types'; export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { providerName: string; @@ -31,9 +32,9 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { requestBody: KubernetesRequestBody, ): Promise { const authToken: string = (await this.getCredentials()).token; - const auth = { ...requestBody.auth }; + const auth = { ...(requestBody.auth as JsonObject) }; if (auth.oidc) { - auth.oidc[this.providerName] = authToken; + (auth.oidc as JsonObject)[this.providerName] = authToken; } else { auth.oidc = { [this.providerName]: authToken }; } diff --git a/yarn.lock b/yarn.lock index bbb27f2c4a..0176682861 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7693,6 +7693,7 @@ __metadata: "@backstage/plugin-kubernetes-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" + "@backstage/types": "workspace:^" "@kubernetes-models/apimachinery": ^1.1.0 "@kubernetes-models/base": ^4.0.1 "@kubernetes/client-node": 0.18.1