Skip to content

Commit

Permalink
Add secondary authentication to Core ES client (#184901)
Browse files Browse the repository at this point in the history
## Summary

Fix #179458

Add a third method to `IScopedClusterClient`, `asSecondaryAuth` which
allow performing requests on behalf of the kibana system users with the
current user as secondary authentication (via the
`es-secondary-authorization` header)
  • Loading branch information
pgayvallet authored Jun 13, 2024
1 parent 36b252b commit 350b34b
Show file tree
Hide file tree
Showing 11 changed files with 603 additions and 61 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {
import type { ElasticsearchClientConfig } from '@kbn/core-elasticsearch-server';
import { configureClient } from './configure_client';
import { ScopedClusterClient } from './scoped_cluster_client';
import { getDefaultHeaders } from './headers';
import { getDefaultHeaders, AUTHORIZATION_HEADER, ES_SECONDARY_AUTH_HEADER } from './headers';
import {
createInternalErrorHandler,
type InternalUnauthorizedErrorHandler,
Expand Down Expand Up @@ -78,28 +78,43 @@ export class ClusterClient implements ICustomClusterClient {
kibanaVersion,
});
this.rootScopedClient = configureClient(config, {
scoped: true,
logger,
type,
getExecutionContext,
scoped: true,
agentFactoryProvider,
kibanaVersion,
});
}

asScoped(request: ScopeableRequest) {
const scopedHeaders = this.getScopedHeaders(request);
const createScopedClient = () => {
const scopedHeaders = this.getScopedHeaders(request);

const transportClass = createTransport({
getExecutionContext: this.getExecutionContext,
getUnauthorizedErrorHandler: this.createInternalErrorHandlerAccessor(request),
});
const transportClass = createTransport({
getExecutionContext: this.getExecutionContext,
getUnauthorizedErrorHandler: this.createInternalErrorHandlerAccessor(request),
});

return this.rootScopedClient.child({
headers: scopedHeaders,
Transport: transportClass,
});
};

const createSecondaryScopedClient = () => {
const secondaryAuthHeaders = this.getSecondaryAuthHeaders(request);

return this.asInternalUser.child({
headers: secondaryAuthHeaders,
});
};

const scopedClient = this.rootScopedClient.child({
headers: scopedHeaders,
Transport: transportClass,
return new ScopedClusterClient({
asInternalUser: this.asInternalUser,
asCurrentUserFactory: createScopedClient,
asSecondaryAuthUserFactory: createSecondaryScopedClient,
});
return new ScopedClusterClient(this.asInternalUser, scopedClient);
}

public async close() {
Expand Down Expand Up @@ -129,7 +144,7 @@ export class ClusterClient implements ICustomClusterClient {
if (isRealRequest(request)) {
const requestHeaders = ensureRawRequest(request).headers ?? {};
const requestIdHeaders = isKibanaRequest(request) ? { 'x-opaque-id': request.id } : {};
const authHeaders = this.authHeaders ? this.authHeaders.get(request) : {};
const authHeaders = this.authHeaders?.get(request) ?? {};

scopedHeaders = {
...filterHeaders(requestHeaders, this.config.requestHeadersWhitelist),
Expand All @@ -146,4 +161,25 @@ export class ClusterClient implements ICustomClusterClient {
...scopedHeaders,
};
}

private getSecondaryAuthHeaders(request: ScopeableRequest): Headers {
const headerSource = isRealRequest(request)
? this.authHeaders?.get(request) ?? {}
: request.headers;
const authorizationHeader = Object.entries(headerSource).find(([key, value]) => {
return key.toLowerCase() === AUTHORIZATION_HEADER && value !== undefined;
});

if (!authorizationHeader) {
throw new Error(
`asSecondaryAuthUser called from a client scoped to a request without 'authorization' header.`
);
}

return {
...getDefaultHeaders(this.kibanaVersion),
...this.config.customHeaders,
[ES_SECONDARY_AUTH_HEADER]: authorizationHeader[1],
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export const PRODUCT_ORIGIN_HEADER = 'x-elastic-product-origin';
*/
export const USER_AGENT_HEADER = 'user-agent';

/**
* @internal
*/
export const AUTHORIZATION_HEADER = 'authorization';

/**
* @internal
*/
export const ES_SECONDARY_AUTH_HEADER = 'es-secondary-authorization';

/**
* @internal
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,82 @@
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { ScopedClusterClient } from './scoped_cluster_client';

const createEsClient = () => ({} as unknown as ElasticsearchClient);
const createEsClient = () => Symbol('client') as unknown as ElasticsearchClient;

describe('ScopedClusterClient', () => {
it('uses the internal client passed in the constructor', () => {
const internalClient = createEsClient();
const scopedClient = createEsClient();
const secondaryAuthClient = createEsClient();

const scopedClusterClient = new ScopedClusterClient(internalClient, scopedClient);
const scopedClusterClient = new ScopedClusterClient({
asInternalUser: internalClient,
asCurrentUserFactory: () => scopedClient,
asSecondaryAuthUserFactory: () => secondaryAuthClient,
});

expect(scopedClusterClient.asInternalUser).toBe(internalClient);
});

it('uses the scoped client passed in the constructor', () => {
it('uses the primary-auth scoped client factory passed in the constructor', () => {
const internalClient = createEsClient();
const scopedClient = createEsClient();
const secondaryAuthClient = createEsClient();

const scopedClusterClient = new ScopedClusterClient(internalClient, scopedClient);
const scopedClusterClient = new ScopedClusterClient({
asInternalUser: internalClient,
asCurrentUserFactory: () => scopedClient,
asSecondaryAuthUserFactory: () => secondaryAuthClient,
});

expect(scopedClusterClient.asCurrentUser).toBe(scopedClient);
});

it('uses the secondary-auth scoped client factory passed in the constructor', () => {
const internalClient = createEsClient();
const scopedClient = createEsClient();
const secondaryAuthClient = createEsClient();

const scopedClusterClient = new ScopedClusterClient({
asInternalUser: internalClient,
asCurrentUserFactory: () => scopedClient,
asSecondaryAuthUserFactory: () => secondaryAuthClient,
});

expect(scopedClusterClient.asSecondaryAuthUser).toBe(secondaryAuthClient);
});

it('returns the same instance when calling `asCurrentUser` multiple times', () => {
const internalClient = createEsClient();
const scopedClient = createEsClient();
const secondaryAuthClient = createEsClient();

const scopedClusterClient = new ScopedClusterClient({
asInternalUser: internalClient,
asCurrentUserFactory: () => scopedClient,
asSecondaryAuthUserFactory: () => secondaryAuthClient,
});

const userClient1 = scopedClusterClient.asCurrentUser;
const userClient2 = scopedClusterClient.asCurrentUser;

expect(userClient1).toBe(userClient2);
});

it('returns the same instance when calling `asSecondaryAuthUser` multiple times', () => {
const internalClient = createEsClient();
const scopedClient = createEsClient();
const secondaryAuthClient = createEsClient();

const scopedClusterClient = new ScopedClusterClient({
asInternalUser: internalClient,
asCurrentUserFactory: () => scopedClient,
asSecondaryAuthUserFactory: () => secondaryAuthClient,
});

const secondaryAuth1 = scopedClusterClient.asSecondaryAuthUser;
const secondaryAuth2 = scopedClusterClient.asSecondaryAuthUser;

expect(secondaryAuth1).toBe(secondaryAuth2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,39 @@ import type { ElasticsearchClient, IScopedClusterClient } from '@kbn/core-elasti

/** @internal **/
export class ScopedClusterClient implements IScopedClusterClient {
constructor(
public readonly asInternalUser: ElasticsearchClient,
public readonly asCurrentUser: ElasticsearchClient
) {}
public readonly asInternalUser;

readonly #asCurrentUserFactory: () => ElasticsearchClient;
readonly #asSecondaryAuthUserFactory: () => ElasticsearchClient;

#asCurrentUserClient?: ElasticsearchClient;
#asSecondaryAuthUserClient?: ElasticsearchClient;

constructor({
asInternalUser,
asCurrentUserFactory,
asSecondaryAuthUserFactory,
}: {
asInternalUser: ElasticsearchClient;
asCurrentUserFactory: () => ElasticsearchClient;
asSecondaryAuthUserFactory: () => ElasticsearchClient;
}) {
this.asInternalUser = asInternalUser;
this.#asCurrentUserFactory = asCurrentUserFactory;
this.#asSecondaryAuthUserFactory = asSecondaryAuthUserFactory;
}

public get asCurrentUser() {
if (this.#asCurrentUserClient === undefined) {
this.#asCurrentUserClient = this.#asCurrentUserFactory();
}
return this.#asCurrentUserClient;
}

public get asSecondaryAuthUser() {
if (this.#asSecondaryAuthUserClient === undefined) {
this.#asSecondaryAuthUserClient = this.#asSecondaryAuthUserFactory();
}
return this.#asSecondaryAuthUserClient;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,14 @@ const createClientMock = (res?: Promise<unknown>): ElasticsearchClientMock =>
export interface ScopedClusterClientMock {
asInternalUser: ElasticsearchClientMock;
asCurrentUser: ElasticsearchClientMock;
asSecondaryAuthUser: ElasticsearchClientMock;
}

const createScopedClusterClientMock = () => {
const mock: ScopedClusterClientMock = {
asInternalUser: createClientMock(),
asCurrentUser: createClientMock(),
asSecondaryAuthUser: createClientMock(),
};

return mock;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export interface IScopedClusterClient {
* on behalf of the internal Kibana user.
*/
readonly asInternalUser: ElasticsearchClient;

/**
* A {@link ElasticsearchClient | client} to be used to query the elasticsearch cluster
* with the internal Kibana user as primary auth and the current user as secondary auth
* (using the `es-secondary-authorization` header).
*
* Note that only a subset of Elasticsearch APIs support secondary authentication, and that only those endpoints
* should be called with this client.
*/
readonly asSecondaryAuthUser: ElasticsearchClient;

/**
* A {@link ElasticsearchClient | client} to be used to query the elasticsearch cluster
* on behalf of the user that initiated the request to the Kibana server.
Expand Down
51 changes: 40 additions & 11 deletions x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,47 @@ export function createWrappedScopedClusterClientFactory(
};
}

class WrappedScopedClusterClientImpl implements IScopedClusterClient {
#asInternalUser?: ElasticsearchClient;
#asCurrentUser?: ElasticsearchClient;
#asSecondaryAuthUser?: ElasticsearchClient;

constructor(private readonly opts: WrapScopedClusterClientOpts) {}

public get asInternalUser() {
if (this.#asInternalUser === undefined) {
const { scopedClusterClient, ...rest } = this.opts;
this.#asInternalUser = wrapEsClient({
...rest,
esClient: scopedClusterClient.asInternalUser,
});
}
return this.#asInternalUser;
}
public get asCurrentUser() {
if (this.#asCurrentUser === undefined) {
const { scopedClusterClient, ...rest } = this.opts;
this.#asCurrentUser = wrapEsClient({
...rest,
esClient: scopedClusterClient.asCurrentUser,
});
}
return this.#asCurrentUser;
}
public get asSecondaryAuthUser() {
if (this.#asSecondaryAuthUser === undefined) {
const { scopedClusterClient, ...rest } = this.opts;
this.#asSecondaryAuthUser = wrapEsClient({
...rest,
esClient: scopedClusterClient.asSecondaryAuthUser,
});
}
return this.#asSecondaryAuthUser;
}
}

function wrapScopedClusterClient(opts: WrapScopedClusterClientOpts): IScopedClusterClient {
const { scopedClusterClient, ...rest } = opts;
return {
asInternalUser: wrapEsClient({
...rest,
esClient: scopedClusterClient.asInternalUser,
}),
asCurrentUser: wrapEsClient({
...rest,
esClient: scopedClusterClient.asCurrentUser,
}),
};
return new WrappedScopedClusterClientImpl(opts);
}

function wrapEsClient(opts: WrapEsClientOpts): ElasticsearchClient {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ function getRequestItemsProvider(
scopedClient = {
asInternalUser,
asCurrentUser: asInternalUser,
asSecondaryAuthUser: asInternalUser,
};
mlSavedObjectService = getSobSavedObjectService(scopedClient);
mlClient = getMlClient(scopedClient, mlSavedObjectService);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,11 @@ describe('BurnRateRuleExecutor', () => {
loggerMock = loggingSystemMock.createLogger();
servicesMock = {
savedObjectsClient: soClientMock,
scopedClusterClient: { asCurrentUser: esClientMock, asInternalUser: esClientMock },
scopedClusterClient: {
asCurrentUser: esClientMock,
asInternalUser: esClientMock,
asSecondaryAuthUser: esClientMock,
},
alertsClient: publicAlertsClientMock.create(),
alertFactory: {
create: jest.fn(),
Expand Down
Loading

0 comments on commit 350b34b

Please sign in to comment.