diff --git a/plugins/search-backend-module-elasticsearch/src/auth.ts b/plugins/search-backend-module-elasticsearch/src/auth.ts new file mode 100644 index 0000000000..4491bc3b58 --- /dev/null +++ b/plugins/search-backend-module-elasticsearch/src/auth.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2024 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 { createExtensionPoint } from '@backstage/backend-plugin-api'; + +/** + * A provider that supplies authentication headers for Elasticsearch/OpenSearch requests. + * + * @remarks + * + * This interface allows for dynamic authentication mechanisms such as bearer tokens + * that need to be refreshed or rotated. The `getAuthHeaders` method is called before + * each request to the Elasticsearch/OpenSearch cluster, allowing for just-in-time + * token retrieval and automatic rotation. + * + * @example + * + * ```ts + * const authProvider: ElasticSearchAuthProvider = { + * async getAuthHeaders() { + * const token = await myTokenService.getToken(); + * return { Authorization: `Bearer ${token}` }; + * }, + * }; + * ``` + * + * @public + */ +export interface ElasticSearchAuthProvider { + /** + * Returns authentication headers to be included in requests to Elasticsearch/OpenSearch. + * + * @remarks + * + * This method is called before each request, allowing for dynamic token refresh + * and rotation. Implementations should handle caching internally if needed to + * avoid excessive token generation. + * + * @returns A promise that resolves to a record of header names and values + */ + getAuthHeaders(): Promise>; +} + +/** + * Extension point for providing custom authentication to the Elasticsearch search engine. + * + * @remarks + * + * Use this extension point to provide dynamic authentication mechanisms such as + * bearer tokens with automatic rotation. When an auth provider is set, it takes + * precedence over any static authentication configured in app-config.yaml. + * + * @example + * + * ```ts + * import { createBackendModule } from '@backstage/backend-plugin-api'; + * import { elasticsearchAuthExtensionPoint } from '@backstage/plugin-search-backend-module-elasticsearch'; + * + * export default createBackendModule({ + * pluginId: 'search', + * moduleId: 'elasticsearch-custom-auth', + * register(env) { + * env.registerInit({ + * deps: { + * elasticsearchAuth: elasticsearchAuthExtensionPoint, + * }, + * async init({ elasticsearchAuth }) { + * elasticsearchAuth.setAuthProvider({ + * async getAuthHeaders() { + * const token = await fetchTokenFromIdentityService(); + * return { Authorization: `Bearer ${token}` }; + * }, + * }); + * }, + * }); + * }, + * }); + * ``` + * + * @public + */ +export interface ElasticSearchAuthExtensionPoint { + /** + * Sets the authentication provider for the Elasticsearch search engine. + * + * @remarks + * + * This method can only be called once. Subsequent calls will throw an error. + * The auth provider takes precedence over static authentication configuration. + * + * @param provider - The authentication provider to use + */ + setAuthProvider(provider: ElasticSearchAuthProvider): void; +} + +/** + * Extension point used to customize Elasticsearch/OpenSearch authentication. + * + * @public + */ +export const elasticsearchAuthExtensionPoint = + createExtensionPoint({ + id: 'search.elasticsearchEngine.auth', + }); diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchAuthTransport.test.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchAuthTransport.test.ts new file mode 100644 index 0000000000..0264f9b93c --- /dev/null +++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchAuthTransport.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright 2024 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 { + createOpenSearchAuthTransport, + createElasticSearchAuthTransport, +} from './ElasticSearchAuthTransport'; +import { ElasticSearchAuthProvider } from '../auth'; + +describe('ElasticSearchAuthTransport', () => { + describe('createOpenSearchAuthTransport', () => { + it('should return a Transport class', () => { + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test' }), + }; + + const Transport = createOpenSearchAuthTransport(authProvider); + + expect(Transport).toBeDefined(); + expect(typeof Transport).toBe('function'); + }); + + it('should call getAuthHeaders when request is made', async () => { + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test-token' }), + }; + + const Transport = createOpenSearchAuthTransport(authProvider); + + // Create a mock parent class request method + const mockParentRequest = jest.fn().mockResolvedValue({ body: {} }); + + // Create an instance with mocked internals + const transportInstance = Object.create(Transport.prototype); + Object.getPrototypeOf(Object.getPrototypeOf(transportInstance)).request = + mockParentRequest; + + // Call request method (promise style) + const requestPromise = transportInstance.request( + { method: 'GET', path: '/' }, + {}, + ); + + // The auth provider should be called + await requestPromise.catch(() => {}); // Ignore errors from incomplete mock + expect(authProvider.getAuthHeaders).toHaveBeenCalled(); + }); + + it('should be usable as Transport option', () => { + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test' }), + }; + + const Transport = createOpenSearchAuthTransport(authProvider); + + // Verify the Transport has the expected static properties + expect(Transport).toHaveProperty('sniffReasons'); + }); + }); + + describe('createElasticSearchAuthTransport', () => { + it('should return a Transport class', () => { + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test' }), + }; + + const Transport = createElasticSearchAuthTransport(authProvider); + + expect(Transport).toBeDefined(); + expect(typeof Transport).toBe('function'); + }); + + it('should call getAuthHeaders when request is made', async () => { + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test-token' }), + }; + + const Transport = createElasticSearchAuthTransport(authProvider); + + // Create a mock parent class request method + const mockParentRequest = jest.fn().mockResolvedValue({ body: {} }); + + // Create an instance with mocked internals + const transportInstance = Object.create(Transport.prototype); + Object.getPrototypeOf(Object.getPrototypeOf(transportInstance)).request = + mockParentRequest; + + // Call request method (promise style) + const requestPromise = transportInstance.request( + { method: 'GET', path: '/' }, + {}, + ); + + // The auth provider should be called + await requestPromise.catch(() => {}); // Ignore errors from incomplete mock + expect(authProvider.getAuthHeaders).toHaveBeenCalled(); + }); + + it('should be usable as Transport option', () => { + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test' }), + }; + + const Transport = createElasticSearchAuthTransport(authProvider); + + // Verify the Transport has the expected static properties + expect(Transport).toHaveProperty('sniffReasons'); + }); + }); + + describe('auth provider integration', () => { + it('should support async token retrieval', async () => { + let tokenRetrievalCount = 0; + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest.fn().mockImplementation(async () => { + tokenRetrievalCount++; + // Simulate async token fetch + await new Promise(resolve => setTimeout(resolve, 10)); + return { Authorization: `Bearer token-${tokenRetrievalCount}` }; + }), + }; + + // Just verify the provider works correctly + const headers1 = await authProvider.getAuthHeaders(); + const headers2 = await authProvider.getAuthHeaders(); + + expect(headers1.Authorization).toBe('Bearer token-1'); + expect(headers2.Authorization).toBe('Bearer token-2'); + expect(authProvider.getAuthHeaders).toHaveBeenCalledTimes(2); + }); + + it('should support token rotation pattern', async () => { + const tokens = ['initial-token', 'rotated-token', 'final-token']; + let currentTokenIndex = 0; + + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest.fn().mockImplementation(async () => { + const token = tokens[currentTokenIndex]; + currentTokenIndex = Math.min( + currentTokenIndex + 1, + tokens.length - 1, + ); + return { Authorization: `Bearer ${token}` }; + }), + }; + + // Simulate multiple requests that would trigger token rotation + const headers1 = await authProvider.getAuthHeaders(); + const headers2 = await authProvider.getAuthHeaders(); + const headers3 = await authProvider.getAuthHeaders(); + + expect(headers1.Authorization).toBe('Bearer initial-token'); + expect(headers2.Authorization).toBe('Bearer rotated-token'); + expect(headers3.Authorization).toBe('Bearer final-token'); + }); + + it('should handle errors in getAuthHeaders gracefully', async () => { + const authProvider: ElasticSearchAuthProvider = { + getAuthHeaders: jest + .fn() + .mockRejectedValue(new Error('Token fetch failed')), + }; + + await expect(authProvider.getAuthHeaders()).rejects.toThrow( + 'Token fetch failed', + ); + }); + }); +}); diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchAuthTransport.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchAuthTransport.ts new file mode 100644 index 0000000000..991fdac71a --- /dev/null +++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchAuthTransport.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2024 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 { Transport as OpenSearchTransport } from '@opensearch-project/opensearch'; +import { Transport as ElasticSearchTransport } from '@elastic/elasticsearch'; +import { ElasticSearchAuthProvider } from '../auth'; +import { ElasticSearchTransportConstructor } from './ElasticSearchClientOptions'; + +/** + * Creates a custom OpenSearch Transport class that injects authentication headers + * from the provided auth provider before each request. + * + * @param authProvider - The authentication provider to use for getting headers + * @returns A Transport class constructor that can be used with the OpenSearch client + * + * @internal + */ +export function createOpenSearchAuthTransport( + authProvider: ElasticSearchAuthProvider, +): ElasticSearchTransportConstructor { + class AuthTransport extends OpenSearchTransport { + request( + params: any, + optionsOrCallback: any = {}, + maybeCallback?: any, + ): any { + // options is optional, so if it's a function, it's the callback + const isOptionsCallback = typeof optionsOrCallback === 'function'; + const options = isOptionsCallback ? {} : optionsOrCallback; + const callback = isOptionsCallback ? optionsOrCallback : maybeCallback; + + // If callback style, wrap it to inject headers + if (callback !== undefined) { + authProvider + .getAuthHeaders() + .then(authHeaders => { + const mergedOptions = { + ...options, + headers: { + ...options.headers, + ...authHeaders, + }, + }; + return super.request(params, mergedOptions, callback); + }) + .catch(callback); + + // Return an abort-able object similar to the AWS transport + return { + abort: () => {}, + }; + } + + // Promise style + return authProvider.getAuthHeaders().then(authHeaders => { + const mergedOptions = { + ...options, + headers: { + ...options.headers, + ...authHeaders, + }, + }; + return super.request(params, mergedOptions); + }); + } + } + + return AuthTransport as unknown as ElasticSearchTransportConstructor; +} + +/** + * Creates a custom Elasticsearch Transport class that injects authentication headers + * from the provided auth provider before each request. + * + * @param authProvider - The authentication provider to use for getting headers + * @returns A Transport class constructor that can be used with the Elasticsearch client + * + * @internal + */ +export function createElasticSearchAuthTransport( + authProvider: ElasticSearchAuthProvider, +): ElasticSearchTransportConstructor { + class AuthTransport extends ElasticSearchTransport { + request(params: any, options?: any, callback?: any): any { + // Handle overloaded signatures + if (typeof options === 'function') { + // Callback style without options + const cb = options; + authProvider + .getAuthHeaders() + .then(authHeaders => { + const mergedOptions = { + headers: authHeaders, + }; + return super.request(params, mergedOptions, cb); + }) + .catch(cb); + + return { abort: () => {} }; + } + + if (callback !== undefined) { + // Callback style with options + authProvider + .getAuthHeaders() + .then(authHeaders => { + const mergedOptions = { + ...options, + headers: { + ...options?.headers, + ...authHeaders, + }, + }; + return super.request(params, mergedOptions, callback); + }) + .catch(callback); + + return { abort: () => {} }; + } + + // Promise style + const result = authProvider.getAuthHeaders().then(authHeaders => { + const mergedOptions = { + ...options, + headers: { + ...options?.headers, + ...authHeaders, + }, + }; + return super.request(params, mergedOptions); + }); + + // Add abort method to match TransportRequestPromise interface + (result as any).abort = () => {}; + return result; + } + } + + return AuthTransport as unknown as ElasticSearchTransportConstructor; +} diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts index 9b1f32c0ba..0b198047ee 100644 --- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts +++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts @@ -996,6 +996,84 @@ describe('ElasticSearchSearchEngine', () => { }), ).not.toThrow(); }); + + it('should accept an authProvider and not require auth config', async () => { + const config = new ConfigReader({ + search: { + elasticsearch: { + node: 'http://test-node', + // No auth config - using authProvider instead + }, + }, + }); + + const authProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test-token' }), + }; + + const engine = await ElasticSearchSearchEngine.fromConfig({ + logger: mockServices.logger.mock(), + config, + authProvider, + }); + + expect(engine).toBeDefined(); + }); + + it('should accept an authProvider with opensearch provider', async () => { + const config = new ConfigReader({ + search: { + elasticsearch: { + provider: 'opensearch', + node: 'http://test-node', + // No auth config - using authProvider instead + }, + }, + }); + + const authProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test-token' }), + }; + + const engine = await ElasticSearchSearchEngine.fromConfig({ + logger: mockServices.logger.mock(), + config, + authProvider, + }); + + expect(engine).toBeDefined(); + }); + + it('should throw error when using authProvider with aws provider', async () => { + const config = new ConfigReader({ + search: { + elasticsearch: { + provider: 'aws', + node: 'http://test-node.us-east-1.es.amazonaws.com', + }, + }, + }); + + const authProvider = { + getAuthHeaders: jest + .fn() + .mockResolvedValue({ Authorization: 'Bearer test-token' }), + }; + + await expect( + ElasticSearchSearchEngine.fromConfig({ + logger: mockServices.logger.mock(), + config, + authProvider, + }), + ).rejects.toThrow( + 'Custom auth provider is not supported with AWS provider', + ); + }); }); }); diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts index c03541d9d0..96c2b99d84 100644 --- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts +++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts @@ -41,6 +41,11 @@ import { DefaultAwsCredentialsManager, } from '@backstage/integration-aws-node'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { ElasticSearchAuthProvider } from '../auth'; +import { + createOpenSearchAuthTransport, + createElasticSearchAuthTransport, +} from './ElasticSearchAuthTransport'; export type { ElasticSearchClientOptions }; @@ -82,6 +87,19 @@ export type ElasticSearchOptions = { aliasPostfix?: string; indexPrefix?: string; translator?: ElasticSearchQueryTranslator; + /** + * An optional authentication provider for dynamic authentication. + * + * @remarks + * + * When provided, this auth provider will be used to inject authentication + * headers into each request to Elasticsearch/OpenSearch. This is useful + * for scenarios requiring dynamic tokens (e.g., bearer tokens with rotation). + * + * The auth provider takes precedence over static authentication configured + * in app-config.yaml. + */ + authProvider?: ElasticSearchAuthProvider; }; /** @@ -180,11 +198,13 @@ export class ElasticSearchSearchEngine implements SearchEngine { aliasPostfix = `search`, indexPrefix = ``, translator, + authProvider, } = options; const credentialProvider = DefaultAwsCredentialsManager.fromConfig(config); const clientOptions = await this.createElasticSearchClientOptions( await credentialProvider?.getCredentialProvider(), config.getConfig('search.elasticsearch'), + authProvider, ); if (clientOptions.provider === 'elastic') { logger.info('Initializing Elastic.co ElasticSearch search engine.'); @@ -504,6 +524,7 @@ export class ElasticSearchSearchEngine implements SearchEngine { private static async createElasticSearchClientOptions( credentialProvider: AwsCredentialProvider, config?: Config, + authProvider?: ElasticSearchAuthProvider, ): Promise { if (!config) { throw new Error('No elastic search config found'); @@ -512,16 +533,26 @@ export class ElasticSearchSearchEngine implements SearchEngine { const sslConfig = clientOptionsConfig?.getOptionalConfig('ssl'); if (config.getOptionalString('provider') === 'elastic') { - const authConfig = config.getConfig('auth'); + const authConfig = authProvider ? undefined : config.getConfig('auth'); return { provider: 'elastic', cloud: { id: config.getString('cloudId'), }, - auth: { - username: authConfig.getString('username'), - password: authConfig.getString('password'), - }, + // When using authProvider, we inject auth via custom Transport, not static auth + ...(authConfig + ? { + auth: { + username: authConfig.getString('username'), + password: authConfig.getString('password'), + }, + } + : {}), + ...(authProvider + ? { + Transport: createElasticSearchAuthTransport(authProvider), + } + : {}), ...(sslConfig ? { ssl: { @@ -533,6 +564,12 @@ export class ElasticSearchSearchEngine implements SearchEngine { }; } if (config.getOptionalString('provider') === 'aws') { + // AWS provider uses SigV4 signing; authProvider is not applicable here + if (authProvider) { + throw new Error( + 'Custom auth provider is not supported with AWS provider. AWS uses SigV4 signing.', + ); + } const requestSigner = new RequestSigner(config.getString('node')); const service = config.getOptionalString('service') ?? requestSigner.service; @@ -560,14 +597,26 @@ export class ElasticSearchSearchEngine implements SearchEngine { }; } if (config.getOptionalString('provider') === 'opensearch') { - const authConfig = config.getConfig('auth'); + const authConfig = authProvider + ? undefined + : config.getOptionalConfig('auth'); return { provider: 'opensearch', node: config.getString('node'), - auth: { - username: authConfig.getString('username'), - password: authConfig.getString('password'), - }, + // When using authProvider, we inject auth via custom Transport, not static auth + ...(authConfig + ? { + auth: { + username: authConfig.getString('username'), + password: authConfig.getString('password'), + }, + } + : {}), + ...(authProvider + ? { + Transport: createOpenSearchAuthTransport(authProvider), + } + : {}), ...(sslConfig ? { ssl: { @@ -578,7 +627,10 @@ export class ElasticSearchSearchEngine implements SearchEngine { : {}), }; } - const authConfig = config.getOptionalConfig('auth'); + // Default provider (standard Elasticsearch) + const authConfig = authProvider + ? undefined + : config.getOptionalConfig('auth'); const auth = authConfig && (authConfig.has('apiKey') @@ -591,7 +643,12 @@ export class ElasticSearchSearchEngine implements SearchEngine { }); return { node: config.getString('node'), - auth, + ...(auth ? { auth } : {}), + ...(authProvider + ? { + Transport: createElasticSearchAuthTransport(authProvider), + } + : {}), ...(sslConfig ? { ssl: { diff --git a/plugins/search-backend-module-elasticsearch/src/index.ts b/plugins/search-backend-module-elasticsearch/src/index.ts index a6acf8cdc8..f043e9fe5e 100644 --- a/plugins/search-backend-module-elasticsearch/src/index.ts +++ b/plugins/search-backend-module-elasticsearch/src/index.ts @@ -22,6 +22,11 @@ export { default } from './module'; export * from './module'; +export type { + ElasticSearchAuthProvider, + ElasticSearchAuthExtensionPoint, +} from './auth'; +export { elasticsearchAuthExtensionPoint } from './auth'; export { decodeElasticSearchPageCursor, ElasticSearchSearchEngine, diff --git a/plugins/search-backend-module-elasticsearch/src/module.ts b/plugins/search-backend-module-elasticsearch/src/module.ts index d4ce249093..d4db5ce90a 100644 --- a/plugins/search-backend-module-elasticsearch/src/module.ts +++ b/plugins/search-backend-module-elasticsearch/src/module.ts @@ -23,6 +23,15 @@ import { ElasticSearchQueryTranslator, ElasticSearchSearchEngine, } from './engines/ElasticSearchSearchEngine'; +import { + type ElasticSearchAuthProvider, + type ElasticSearchAuthExtensionPoint, + elasticsearchAuthExtensionPoint, +} from './auth'; + +// Re-export auth types and extension point +export type { ElasticSearchAuthProvider, ElasticSearchAuthExtensionPoint }; +export { elasticsearchAuthExtensionPoint }; /** @public */ export interface ElasticSearchQueryTranslatorExtensionPoint { @@ -49,6 +58,7 @@ export default createBackendModule({ moduleId: 'elasticsearch-engine', register(env) { let translator: ElasticSearchQueryTranslator | undefined; + let authProvider: ElasticSearchAuthProvider | undefined; env.registerExtensionPoint(elasticsearchTranslatorExtensionPoint, { setTranslator(newTranslator) { @@ -61,6 +71,15 @@ export default createBackendModule({ }, }); + env.registerExtensionPoint(elasticsearchAuthExtensionPoint, { + setAuthProvider(provider) { + if (authProvider) { + throw new Error('ElasticSearch auth provider may only be set once'); + } + authProvider = provider; + }, + }); + env.registerInit({ deps: { searchEngineRegistry: searchEngineRegistryExtensionPoint, @@ -82,11 +101,17 @@ export default createBackendModule({ if (indexPrefix) { logger.info(`Index prefix will be used for indices: ${indexPrefix}`); } + if (authProvider) { + logger.info( + 'Using custom auth provider for ElasticSearch authentication.', + ); + } searchEngineRegistry.setSearchEngine( await ElasticSearchSearchEngine.fromConfig({ logger, config, translator, + authProvider, indexPrefix, }), );