diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index 4573be8fdc0412a..c70d5e461d5bcb8 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -9,6 +9,6 @@ }, "configPath": ["xpack", "upgrade_assistant"], "requiredPlugins": ["management", "data", "licensing", "features", "infra", "share"], - "optionalPlugins": ["usageCollection", "cloud"], + "optionalPlugins": ["usageCollection", "cloud", "security"], "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils"] } diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.test.ts index 8bf9143d93dbcab..8532e2e4eece422 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.test.ts @@ -5,33 +5,171 @@ * 2.0. */ +import { KibanaRequest } from 'src/core/server'; +import { loggingSystemMock, httpServerMock } from 'src/core/server/mocks'; +import { securityMock } from '../../../../security/server/mocks'; import { ReindexSavedObject } from '../../../common/types'; -import { Credential, credentialStoreFactory } from './credential_store'; +import { credentialStoreFactory } from './credential_store'; + +const basicAuthHeader = 'Basic abc'; + +const logMock = loggingSystemMock.create().get(); +const requestMock = KibanaRequest.from( + httpServerMock.createRawRequest({ + headers: { + authorization: basicAuthHeader, + }, + }) +); +const securityStartMock = securityMock.createStart(); + +const reindexOpMock = { + id: 'asdf', + attributes: { indexName: 'test', lastCompletedStep: 1, locked: null }, +} as ReindexSavedObject; describe('credentialStore', () => { - it('retrieves the same credentials for the same state', () => { - const creds = { key: '1' } as Credential; - const reindexOp = { - id: 'asdf', - attributes: { indexName: 'test', lastCompletedStep: 1, locked: null }, - } as ReindexSavedObject; - - const credStore = credentialStoreFactory(); - credStore.set(reindexOp, creds); - expect(credStore.get(reindexOp)).toEqual(creds); + it('retrieves the same credentials for the same state', async () => { + const credStore = credentialStoreFactory(logMock); + + await credStore.set({ + request: requestMock, + reindexOp: reindexOpMock, + security: securityStartMock, + }); + + expect(credStore.get(reindexOpMock)).toEqual({ + authorization: basicAuthHeader, + }); + }); + + it('does not retrieve credentials if the state changed', async () => { + const credStore = credentialStoreFactory(logMock); + + await credStore.set({ + request: requestMock, + reindexOp: reindexOpMock, + security: securityStartMock, + }); + + reindexOpMock.attributes.lastCompletedStep = 0; + + expect(credStore.get(reindexOpMock)).toBeUndefined(); + }); + + it('retrieves credentials after update', async () => { + const credStore = credentialStoreFactory(logMock); + + await credStore.set({ + request: requestMock, + reindexOp: reindexOpMock, + security: securityStartMock, + }); + + const updatedReindexOp = { + ...reindexOpMock, + attributes: { + ...reindexOpMock.attributes, + status: 0, + }, + }; + + await credStore.update({ + credential: { + authorization: basicAuthHeader, + }, + reindexOp: updatedReindexOp, + security: securityStartMock, + }); + + expect(credStore.get(updatedReindexOp)).toEqual({ + authorization: basicAuthHeader, + }); }); - it('does retrieve credentials if the state is changed', () => { - const creds = { key: '1' } as Credential; - const reindexOp = { - id: 'asdf', - attributes: { indexName: 'test', lastCompletedStep: 1, locked: null }, - } as ReindexSavedObject; + describe('API keys enabled', () => { + const apiKeyResultMock = { + id: 'api_key_id', + name: 'api_key_name', + api_key: '123', + }; + + const invalidateApiKeyResultMock = { + invalidated_api_keys: [apiKeyResultMock.api_key], + previously_invalidated_api_keys: [], + error_count: 0, + }; + + const base64ApiKey = Buffer.from(`${apiKeyResultMock.id}:${apiKeyResultMock.api_key}`).toString( + 'base64' + ); + + beforeEach(() => { + securityStartMock.authc.apiKeys.areAPIKeysEnabled.mockReturnValue(Promise.resolve(true)); + securityStartMock.authc.apiKeys.grantAsInternalUser.mockReturnValue( + Promise.resolve(apiKeyResultMock) + ); + securityStartMock.authc.apiKeys.invalidateAsInternalUser.mockReturnValue( + Promise.resolve(invalidateApiKeyResultMock) + ); + }); + + it('sets API key in authorization header', async () => { + const credStore = credentialStoreFactory(logMock); + + await credStore.set({ + request: requestMock, + reindexOp: reindexOpMock, + security: securityStartMock, + }); + + expect(credStore.get(reindexOpMock)).toEqual({ + authorization: `ApiKey ${base64ApiKey}`, + }); + }); + + it('invalidates API keys when a reindex operation is complete', async () => { + const credStore = credentialStoreFactory(logMock); + + await credStore.set({ + request: requestMock, + reindexOp: reindexOpMock, + security: securityStartMock, + }); + + await credStore.update({ + credential: { + authorization: `ApiKey ${base64ApiKey}`, + }, + reindexOp: { + ...reindexOpMock, + attributes: { + ...reindexOpMock.attributes, + status: 1, + }, + }, + security: securityStartMock, + }); + + expect(securityStartMock.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalled(); + }); + + it('falls back to user credentials when error granting API key', async () => { + const credStore = credentialStoreFactory(logMock); + + securityStartMock.authc.apiKeys.grantAsInternalUser.mockRejectedValue( + new Error('Error granting API key') + ); - const credStore = credentialStoreFactory(); - credStore.set(reindexOp, creds); + await credStore.set({ + request: requestMock, + reindexOp: reindexOpMock, + security: securityStartMock, + }); - reindexOp.attributes.lastCompletedStep = 0; - expect(credStore.get(reindexOp)).not.toBeDefined(); + expect(credStore.get(reindexOpMock)).toEqual({ + authorization: basicAuthHeader, + }); + }); }); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.ts index 2c4f86824518a7a..66885a23cf96b6e 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.ts @@ -8,10 +8,73 @@ import { createHash } from 'crypto'; import stringify from 'json-stable-stringify'; -import { ReindexSavedObject } from '../../../common/types'; +import { KibanaRequest, Logger } from 'src/core/server'; + +import { SecurityPluginStart } from '../../../../security/server'; +import { ReindexSavedObject, ReindexStatus } from '../../../common/types'; export type Credential = Record; +// Generates a stable hash for the reindex operation's current state. +const getHash = (reindexOp: ReindexSavedObject) => + createHash('sha256') + .update(stringify({ id: reindexOp.id, ...reindexOp.attributes })) + .digest('base64'); + +// Returns a base64-encoded API key string or undefined +const getApiKey = async ({ + request, + security, + reindexOpId, + apiKeysMap, +}: { + request: KibanaRequest; + security: SecurityPluginStart; + reindexOpId: string; + apiKeysMap: Map; +}): Promise => { + try { + const apiKeyResult = await security.authc.apiKeys.grantAsInternalUser(request, { + name: `ua_reindex_${reindexOpId}`, + role_descriptors: {}, + metadata: { + description: + 'Created by the Upgrade Assistant for a reindex operation; this can be safely deleted after Kibana is upgraded.', + }, + }); + + if (apiKeyResult) { + const { api_key: apiKey, id } = apiKeyResult; + // Store each API key per reindex operation so that we can later invalidate it when the reindex operation is complete + apiKeysMap.set(reindexOpId, id); + // Returns the base64 encoding of `id:api_key` + // This can be used when sending a request with an "Authorization: ApiKey xxx" header + return Buffer.from(`${id}:${apiKey}`).toString('base64'); + } + } catch (error) { + // There are a few edge cases were granting an API key could fail, + // in which case we fall back to using the requestor's credentials in memory + return undefined; + } +}; + +const invalidateApiKey = async ({ + apiKeyId, + security, + log, +}: { + apiKeyId: string; + security?: SecurityPluginStart; + log: Logger; +}) => { + try { + await security?.authc.apiKeys.invalidateAsInternalUser({ ids: [apiKeyId] }); + } catch (error) { + // Swallow error if there's a problem invalidating API key + log.debug(`Error invalidating API key for id ${apiKeyId}: ${error.message}`); + } +}; + /** * An in-memory cache for user credentials to be used for reindexing operations. When looking up * credentials, the reindex operation must be in the same state it was in when the credentials @@ -20,25 +83,82 @@ export type Credential = Record; */ export interface CredentialStore { get(reindexOp: ReindexSavedObject): Credential | undefined; - set(reindexOp: ReindexSavedObject, credential: Credential): void; + set(params: { + reindexOp: ReindexSavedObject; + request: KibanaRequest; + security?: SecurityPluginStart; + }): Promise; + update(params: { + reindexOp: ReindexSavedObject; + security?: SecurityPluginStart; + credential: Credential; + }): Promise; clear(): void; } -export const credentialStoreFactory = (): CredentialStore => { +export const credentialStoreFactory = (logger: Logger): CredentialStore => { const credMap = new Map(); - - // Generates a stable hash for the reindex operation's current state. - const getHash = (reindexOp: ReindexSavedObject) => - createHash('sha256') - .update(stringify({ id: reindexOp.id, ...reindexOp.attributes })) - .digest('base64'); + const apiKeysMap = new Map(); + const log = logger.get('credential_store'); return { get(reindexOp: ReindexSavedObject) { return credMap.get(getHash(reindexOp)); }, - set(reindexOp: ReindexSavedObject, credential: Credential) { + async set({ + reindexOp, + request, + security, + }: { + reindexOp: ReindexSavedObject; + request: KibanaRequest; + security?: SecurityPluginStart; + }) { + const areApiKeysEnabled = (await security?.authc.apiKeys.areAPIKeysEnabled()) ?? false; + + if (areApiKeysEnabled) { + const apiKey = await getApiKey({ + request, + security: security!, + reindexOpId: reindexOp.id, + apiKeysMap, + }); + + if (apiKey) { + credMap.set(getHash(reindexOp), { + ...request.headers, + authorization: `ApiKey ${apiKey}`, + }); + return; + } + } + + // Set the requestor's credentials in memory if apiKeys are not enabled + credMap.set(getHash(reindexOp), request.headers); + }, + + async update({ + reindexOp, + security, + credential, + }: { + reindexOp: ReindexSavedObject; + security?: SecurityPluginStart; + credential: Credential; + }) { + // If the reindex operation is completed... + if (reindexOp.attributes.status === ReindexStatus.completed) { + // ...and an API key is being used, invalidate it + const apiKeyId = apiKeysMap.get(reindexOp.id); + if (apiKeyId) { + await invalidateApiKey({ apiKeyId, security, log }); + apiKeysMap.delete(reindexOp.id); + return; + } + } + + // Otherwise, re-associate the credentials credMap.set(getHash(reindexOp), credential); }, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts index c598da93388c3f3..3491c92ef59533a 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -7,6 +7,7 @@ import { IClusterClient, Logger, SavedObjectsClientContract, FakeRequest } from 'src/core/server'; import moment from 'moment'; +import { SecurityPluginStart } from '../../../../security/server'; import { ReindexSavedObject, ReindexStatus } from '../../../common/types'; import { Credential, CredentialStore } from './credential_store'; import { reindexActionsFactory } from './reindex_actions'; @@ -46,15 +47,19 @@ export class ReindexWorker { private inProgressOps: ReindexSavedObject[] = []; private readonly reindexService: ReindexService; private readonly log: Logger; + private readonly security: SecurityPluginStart; constructor( private client: SavedObjectsClientContract, private credentialStore: CredentialStore, private clusterClient: IClusterClient, log: Logger, - private licensing: LicensingPluginSetup + private licensing: LicensingPluginSetup, + security: SecurityPluginStart ) { this.log = log.get('reindex_worker'); + this.security = security; + if (ReindexWorker.workerSingleton) { throw new Error(`More than one ReindexWorker cannot be created.`); } @@ -171,7 +176,11 @@ export class ReindexWorker { firstOpInQueue.attributes.indexName ); // Re-associate the credentials - this.credentialStore.set(firstOpInQueue, credential); + this.credentialStore.update({ + reindexOp: firstOpInQueue, + security: this.security, + credential, + }); } } @@ -223,7 +232,7 @@ export class ReindexWorker { reindexOp = await swallowExceptions(service.processNextStep, this.log)(reindexOp); // Update credential store with most recent state. - this.credentialStore.set(reindexOp, credential); + this.credentialStore.update({ reindexOp, security: this.security, credential }); }; } diff --git a/x-pack/plugins/upgrade_assistant/server/plugin.ts b/x-pack/plugins/upgrade_assistant/server/plugin.ts index b47400c065bdd01..4c46b923f0650d9 100644 --- a/x-pack/plugins/upgrade_assistant/server/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/server/plugin.ts @@ -6,7 +6,6 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; - import { Plugin, CoreSetup, @@ -16,6 +15,7 @@ import { SavedObjectsClient, SavedObjectsServiceStart, } from '../../../../src/core/server'; +import { SecurityPluginStart } from '../../security/server'; import { InfraPluginSetup } from '../../infra/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; @@ -44,6 +44,10 @@ interface PluginsSetup { infra: InfraPluginSetup; } +interface PluginsStart { + security: SecurityPluginStart; +} + export class UpgradeAssistantServerPlugin implements Plugin { private readonly logger: Logger; private readonly credentialStore: CredentialStore; @@ -54,11 +58,12 @@ export class UpgradeAssistantServerPlugin implements Plugin { // Properties set at start private savedObjectsServiceStart?: SavedObjectsServiceStart; + private securityPluginStart?: SecurityPluginStart; private worker?: ReindexWorker; constructor({ logger, env }: PluginInitializerContext) { this.logger = logger.get(); - this.credentialStore = credentialStoreFactory(); + this.credentialStore = credentialStoreFactory(this.logger); this.kibanaVersion = env.packageInfo.version; } @@ -120,6 +125,7 @@ export class UpgradeAssistantServerPlugin implements Plugin { } return this.savedObjectsServiceStart; }, + getSecurityPlugin: () => this.securityPluginStart, lib: { handleEsError, }, @@ -141,8 +147,9 @@ export class UpgradeAssistantServerPlugin implements Plugin { } } - start({ savedObjects, elasticsearch }: CoreStart) { + start({ savedObjects, elasticsearch }: CoreStart, { security }: PluginsStart) { this.savedObjectsServiceStart = savedObjects; + this.securityPluginStart = security; // The ReindexWorker uses a map of request headers that contain the authentication credentials // for a given reindex. We cannot currently store these in an the .kibana index b/c we do not @@ -159,6 +166,7 @@ export class UpgradeAssistantServerPlugin implements Plugin { savedObjects: new SavedObjectsClient( this.savedObjectsServiceStart.createInternalRepository() ), + security: this.securityPluginStart, }); this.worker.start(); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts index fe9b95787b7d1dd..d81dc8cec4c532e 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts @@ -6,9 +6,15 @@ */ import { i18n } from '@kbn/i18n'; -import { IScopedClusterClient, Logger, SavedObjectsClientContract } from 'kibana/server'; +import { + IScopedClusterClient, + Logger, + SavedObjectsClientContract, + KibanaRequest, +} from 'kibana/server'; import { LicensingPluginSetup } from '../../../../licensing/server'; +import { SecurityPluginStart } from '../../../../security/server'; import { ReindexOperation, ReindexStatus } from '../../../common/types'; @@ -23,22 +29,24 @@ interface ReindexHandlerArgs { indexName: string; log: Logger; licensing: LicensingPluginSetup; - headers: Record; + request: KibanaRequest; credentialStore: CredentialStore; reindexOptions?: { enqueue?: boolean; }; + security?: SecurityPluginStart; } export const reindexHandler = async ({ credentialStore, dataClient, - headers, + request, indexName, licensing, log, savedObjects, reindexOptions, + security, }: ReindexHandlerArgs): Promise => { const callAsCurrentUser = dataClient.asCurrentUser; const reindexActions = reindexActionsFactory(savedObjects, callAsCurrentUser); @@ -62,7 +70,7 @@ export const reindexHandler = async ({ : await reindexService.createReindexOperation(indexName, reindexOptions); // Add users credentials for the worker to use - credentialStore.set(reindexOp, headers); + await credentialStore.set({ reindexOp, request, security }); return reindexOp.attributes; }; diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts index 08d9995ee62194f..44331799a160bc9 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts @@ -6,7 +6,9 @@ */ import { kibanaResponseFactory } from 'src/core/server'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { licensingMock } from '../../../../licensing/server/mocks'; +import { securityMock } from '../../../../security/server/mocks'; import { createMockRouter, MockRouter, routeHandlerContextMock } from '../__mocks__/routes.mock'; import { createRequestMock } from '../__mocks__/request.mock'; @@ -35,6 +37,8 @@ import { IndexGroup, ReindexSavedObject, ReindexStatus } from '../../../common/t import { credentialStoreFactory } from '../../lib/reindexing/credential_store'; import { registerReindexIndicesRoutes } from './reindex_indices'; +const logMock = loggingSystemMock.create().get(); + /** * Since these route callbacks are so thin, these serve simply as integration tests * to ensure they're wired up to the lib functions correctly. Business logic is tested @@ -44,7 +48,7 @@ describe('reindex API', () => { let routeDependencies: any; let mockRouter: MockRouter; - const credentialStore = credentialStoreFactory(); + const credentialStore = credentialStoreFactory(logMock); const worker = { includes: jest.fn(), forceRefresh: jest.fn(), @@ -56,6 +60,7 @@ describe('reindex API', () => { credentialStore, router: mockRouter, licensing: licensingMock.createSetup(), + getSecurityPlugin: () => securityMock.createStart(), }; registerReindexIndicesRoutes(routeDependencies, () => worker); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts index 5528c0847822a64..6edc03d86cb8580 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts @@ -15,6 +15,7 @@ import { } from '../../../../../../src/core/server'; import { LicensingPluginSetup } from '../../../../licensing/server'; +import { SecurityPluginStart } from '../../../../security/server'; import { ReindexStatus } from '../../../common/types'; @@ -45,6 +46,7 @@ interface CreateReindexWorker { credentialStore: CredentialStore; savedObjects: SavedObjectsClient; licensing: LicensingPluginSetup; + security: SecurityPluginStart; } export function createReindexWorker({ @@ -53,9 +55,10 @@ export function createReindexWorker({ credentialStore, savedObjects, licensing, + security, }: CreateReindexWorker) { const esClient = elasticsearchService.client; - return new ReindexWorker(savedObjects, credentialStore, esClient, logger, licensing); + return new ReindexWorker(savedObjects, credentialStore, esClient, logger, licensing, security); } const mapAnyErrorToKibanaHttpResponse = (e: any) => { @@ -83,7 +86,7 @@ const mapAnyErrorToKibanaHttpResponse = (e: any) => { }; export function registerReindexIndicesRoutes( - { credentialStore, router, licensing, log }: RouteDependencies, + { credentialStore, router, licensing, log, getSecurityPlugin }: RouteDependencies, getWorker: () => ReindexWorker ) { const BASE_PATH = `${API_BASE_PATH}/reindex`; @@ -117,8 +120,9 @@ export function registerReindexIndicesRoutes( indexName, log, licensing, - headers: request.headers, + request, credentialStore, + security: getSecurityPlugin(), }); // Kick the worker on this node to immediately pickup the new reindex operation. @@ -202,11 +206,12 @@ export function registerReindexIndicesRoutes( indexName, log, licensing, - headers: request.headers, + request, credentialStore, reindexOptions: { enqueue: true, }, + security: getSecurityPlugin(), }); results.enqueued.push(result); } catch (e) { diff --git a/x-pack/plugins/upgrade_assistant/server/types.ts b/x-pack/plugins/upgrade_assistant/server/types.ts index 6c9ed3e5171189d..09272d270333e12 100644 --- a/x-pack/plugins/upgrade_assistant/server/types.ts +++ b/x-pack/plugins/upgrade_assistant/server/types.ts @@ -7,6 +7,7 @@ import { IRouter, Logger, SavedObjectsServiceStart } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; +import { SecurityPluginStart } from '../../security/server'; import { CredentialStore } from './lib/reindexing/credential_store'; import { handleEsError } from './shared_imports'; @@ -15,6 +16,7 @@ export interface RouteDependencies { credentialStore: CredentialStore; log: Logger; getSavedObjectsService: () => SavedObjectsServiceStart; + getSecurityPlugin: () => SecurityPluginStart | undefined; licensing: LicensingPluginSetup; lib: { handleEsError: typeof handleEsError;