Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Upgrade Assistant] Add support for API keys when reindexing #111451

Merged
merged 11 commits into from
Sep 14, 2021
2 changes: 1 addition & 1 deletion x-pack/plugins/upgrade_assistant/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,153 @@
* 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)).not.toBeDefined();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be more succinct to express not.toBeDefined() as 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,
});

const credStore = credentialStoreFactory();
credStore.set(reindexOp, creds);
await credStore.update({
credential: {
authorization: `ApiKey ${base64ApiKey}`,
},
reindexOp: {
...reindexOpMock,
attributes: {
...reindexOpMock.attributes,
status: 1,
},
},
security: securityStartMock,
});

reindexOp.attributes.lastCompletedStep = 0;
expect(credStore.get(reindexOp)).not.toBeDefined();
expect(securityStartMock.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
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<string, any>;

Expand All @@ -20,25 +23,119 @@ export type Credential = Record<string, any>;
*/
export interface CredentialStore {
get(reindexOp: ReindexSavedObject): Credential | undefined;
set(reindexOp: ReindexSavedObject, credential: Credential): void;
set(params: {
reindexOp: ReindexSavedObject;
request: KibanaRequest;
security?: SecurityPluginStart;
}): Promise<void>;
update(params: {
reindexOp: ReindexSavedObject;
security?: SecurityPluginStart;
credential: Credential;
}): Promise<void>;
clear(): void;
}

export const credentialStoreFactory = (): CredentialStore => {
export const credentialStoreFactory = (logger: Logger): CredentialStore => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I would find this code easier to digest if we extracted getHash, getApiKey, and invalidateApiKey from the factory function's scope. This would make credentialStoreFactory smaller, so I'd have less information to keep in my head as I read the function. Extracting out the helpers also makes it easier for me to identify their dependencies.

// 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 getApiKey = async ({
  request,
  security,
  reindexOpId,
  apiKeysMap,
}: {
  request: KibanaRequest;
  security?: SecurityPluginStart;
  reindexOpId: string;
  apiKeysMap: Map<string, string>;
}): Promise<string | undefined> => {
  const apiKeyResult = await security?.authc.apiKeys.grantAsInternalUser(request, {
    name: `ua_reindex_${reindexOpId}`,
    role_descriptors: {},
  });

  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');
  }
};

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}`);
  }
};

export const credentialStoreFactory = (logger: Logger): CredentialStore => {
  /* ... */
};

const credMap = new Map<string, Credential>();
const apiKeysMap = new Map<string, string>();
const log = logger.get('credential_store');

// 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 getApiKey = async ({
request,
security,
reindexOpId,
}: {
request: KibanaRequest;
security?: SecurityPluginStart;
reindexOpId: string;
}): Promise<string | undefined> => {
const apiKeyResult = await security?.authc.apiKeys.grantAsInternalUser(request, {
name: `ua_reindex_${reindexOpId}`,
role_descriptors: {},
});
Copy link
Contributor

@jportner jportner Sep 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional nit: given the chance that an API key might not get deleted, maybe it's worth adding a bit of metadata? Something like this:

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 7f3f4f2b307..41eb7a5afa7 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
@@ -59,6 +59,10 @@ export const credentialStoreFactory = (logger: Logger): CredentialStore => {
     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) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point! Done.


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');
}
};
Copy link
Contributor

@jportner jportner Sep 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two edge cases that come to mind:

  1. When the user is already authenticated with an API key, they cannot create another API key (related: Ability to clone granted API keys elasticsearch#59304)
  2. When the user is impersonating another user (with run as), they cannot create an API key

So I think that we probably need some additional logic to handle those edge cases, e.g., catch an error and fall back to just using the given credentials.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the background! I've added a try/catch, which falls back to the user's credentials.


const invalidateApiKey = async ({
apiKeyId,
security,
}: {
apiKeyId: string;
security?: SecurityPluginStart;
}) => {
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}`);
}
};

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;

const apiKey =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we could simplify this a bit my moving logic into a condition that API keys are enabled:

      if (areApiKeysEnabled) {
        const apiKey = await getApiKey({ request, 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);

Once we're inside the condition we know that security is defined, so we can also change the getApiKey signature to expect it, which also makes it simpler:

const getApiKey = async ({
  request,
  security,
  reindexOpId,
  apiKeysMap,
}: {
  request: KibanaRequest;
  security: SecurityPluginStart; // <-- No longer possibly undefined
  reindexOpId: string;
  apiKeysMap: Map<string, string>;
}): Promise<string | undefined> => {
  /* ... */
};

areApiKeysEnabled && (await getApiKey({ request, security, reindexOpId: reindexOp.id }));

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, and an API key is being used, invalidate it
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nittiest nit-pick ever: can we split this comment across two lines? As it reads, it sounds like line 138's comment means that we'll re-associate credentials if an API key isn't being used.

      // 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 });
          return;
        }
      }

if (reindexOp.attributes.status === ReindexStatus.completed) {
const apiKeyId = apiKeysMap.get(reindexOp.id);
if (apiKeyId) {
await invalidateApiKey({ apiKeyId, security });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also want to delete the API key from apiKeysMap?

apiKeysMap.delete(reindexOp.id);

return;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only downside of this is that it's an in-memory map, so if Kibana restarts for some reason before the reindex is finished, that API key won't ever get deleted.
These API keys use a consistent naming convention, and it doesn't constitute a vulnerability of any sort, so that is acceptable in my book, just wanted to point it out.


// Otherwise, re-associate the credentials
credMap.set(getHash(reindexOp), credential);
},

Expand Down
15 changes: 12 additions & 3 deletions x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.`);
}
Expand Down Expand Up @@ -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,
});
}
}

Expand Down Expand Up @@ -230,7 +239,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 });
};
}

Expand Down
Loading