Skip to content

Commit

Permalink
Manage Cross-Cluster API keys (elastic#162363)
Browse files Browse the repository at this point in the history
Resolves elastic#142400

## Release notes

Added ability to manage Cross-Cluster API keys. 

## Summary

- Redesigned API keys page to cater for different API key types: 
- **Personal API Key** - Allows external services to access the Elastic
Stack on behalf of a user.
- **Cross-Cluster API key** - Allows remote clusters to connect to your
local cluster.
- **Managed API key** - Created and managed by Kibana to correctly run
background tasks. (e.g. alerting / fleet)
- Redesigned Create/Update API key popup to allow adding Cross-Cluster
API keys.
- Redesigned View API key popup showing more information and added
feedback when API keys cannot be edited.

## Technical notes

- Refactored API key schemas and types throughout all API key related
routes, services and clients to create a single source of truth and stop
types going out of sync.
- Consolidated internal endpoints to simplify usage and reduce
unnecessary HTTP roundtrips.
- Migrated API Key form to Formik to more reliably manage state and to
simplify validation logic when toggling visibility of form elements.
- Broke out API key table and related primitives into separate reusable
components for a more cohesive design.

## Screenshots

### API keys page

<details>
  <summary>Before</summary>

<img width="1249" alt="Screenshot 2023-07-24 at 10 25 04"
src="https://github.com/elastic/kibana/assets/190132/eb122597-f138-4658-9141-fd76b3291751">
</details>

<details open>
  <summary>After</summary>

<img width="1159" alt="Screenshot 2023-07-23 at 17 01 24"
src="https://github.com/elastic/kibana/assets/190132/42be5002-235e-4785-83e3-eb4063ca75ba">
</details>

### Create Cross-Cluster API key flyover

<details>
  <summary>Before</summary>

<img width="1261" alt="Screenshot 2023-07-24 at 10 25 21"
src="https://github.com/elastic/kibana/assets/190132/9e3ddffc-e6ec-4c9a-aaa4-a20b0ecf4d51">
</details>

<details open>
  <summary>After</summary>

<img width="1172" alt="Screenshot 2023-07-23 at 17 06 41"
src="https://github.com/elastic/kibana/assets/190132/e6823c07-2154-4777-820f-a3bab9cabe0f">
</details>

### API key details flyover

<details>
  <summary>Before</summary>

<img width="1262" alt="Screenshot 2023-07-24 at 10 25 35"
src="https://github.com/elastic/kibana/assets/190132/212293f3-355b-40d3-a1d6-5eea9c61c1cf">
</details>

<details open>
  <summary>After</summary>

<img width="1260" alt="Screenshot 2023-07-24 at 10 34 01"
src="https://github.com/elastic/kibana/assets/190132/a5f561af-e415-49dd-9f3f-70250eb32ef7">
</details>

## Testing

### Conditions of satisfaction

1) The API Key management screen should be updated to allow the
different API Key types to be distinguished from one another.
2) Users with the elevated `manage_security` cluster privilege shall
have the ability to create & update RCS API Keys, as described in the
technical document provided by ES. This is a different privilege than
what the rest of the screen requires today.
3) API Key Invalidation will require the existing `manage_api_keys`
cluster privilege.
4) RCS API Keys are not available in the serverless offering, so these
changes should only be visible for the current offering.

Note: This functionality requires a true serverless ES instance - The
feature will not be hidden simply by running Kibana using serverless
flag

5) RCS API Keys require an `enterprise` license, and should not be
available for deployments with a lesser license level.
6) Any new APIs introduced on the Kibana side should be marked as
`internal`, for consistency with the rest of our API Key endpoints.
7) These RCS API Key changes must not be visible in the UI until support
for this is enabled in ES.
  • Loading branch information
thomheymann authored and bryce-b committed Aug 9, 2023
1 parent 485ccc4 commit 8c9411c
Show file tree
Hide file tree
Showing 35 changed files with 2,254 additions and 1,954 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,27 @@ const KibanaReactContext = createKibanaReactContext(coreMock);

const agentKeys: ApiKey[] = [
{
type: 'rest',
id: 'M96XSX4BQcLuJqE2VX29',
name: 'apm_api_key1',
creation: 1641912161726,
invalidated: false,
username: 'elastic',
realm: 'reserved',
expiration: 0,
role_descriptors: {},
metadata: { application: 'apm' },
},

{
type: 'rest',
id: 'Nd6XSX4BQcLuJqE2eH2A',
name: 'apm_api_key2',
creation: 1641912170624,
invalidated: false,
username: 'elastic',
realm: 'reserved',
expiration: 0,
role_descriptors: {},
metadata: { application: 'apm' },
},
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
* 2.0.
*/

import type { CreateAPIKeyParams } from '@kbn/security-plugin/server';
import type {
CreateRestAPIKeyParams,
CreateRestAPIKeyWithKibanaPrivilegesParams,
} from '@kbn/security-plugin/server';
import type { FakeRawRequest, Headers } from '@kbn/core-http-server';
import { CoreKibanaRequest } from '@kbn/core-http-router-server-internal';

Expand Down Expand Up @@ -60,7 +63,7 @@ export async function generateTransformSecondaryAuthHeaders({
}: {
authorizationHeader: HTTPAuthorizationHeader | null | undefined;
logger: Logger;
createParams?: CreateAPIKeyParams;
createParams?: CreateRestAPIKeyParams | CreateRestAPIKeyWithKibanaPrivilegesParams;
username?: string;
pkgName?: string;
pkgVersion?: string;
Expand Down
78 changes: 65 additions & 13 deletions x-pack/plugins/security/common/model/api_key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,75 @@
* 2.0.
*/

import type { Role } from './role';
import type { estypes } from '@elastic/elasticsearch';

export interface ApiKey {
id: string;
name: string;
username: string;
realm: string;
creation: number;
expiration: number;
invalidated: boolean;
metadata: Record<string, any>;
role_descriptors?: Record<string, any>;
/**
* Interface representing an API key the way it is returned by Elasticsearch GET endpoint.
*/
export type ApiKey = RestApiKey | CrossClusterApiKey;

/**
* Interface representing a REST API key the way it is returned by Elasticsearch GET endpoint.
*
* TODO: Remove this type when `@elastic/elasticsearch` has been updated.
*/
export interface RestApiKey extends BaseApiKey {
type: 'rest';
}

/**
* Interface representing a Cross-Cluster API key the way it is returned by Elasticsearch GET endpoint.
*
* TODO: Remove this type when `@elastic/elasticsearch` has been updated.
*/
export interface CrossClusterApiKey extends BaseApiKey {
type: 'cross_cluster';

/**
* The access to be granted to this API key. The access is composed of permissions for cross-cluster
* search and cross-cluster replication. At least one of them must be specified.
*/
access: CrossClusterApiKeyAccess;
}

/**
* Fixing up `estypes.SecurityApiKey` type since some fields are marked as optional even though they are guaranteed to be returned.
*
* TODO: Remove this type when `@elastic/elasticsearch` has been updated.
*/
interface BaseApiKey extends estypes.SecurityApiKey {
username: Required<estypes.SecurityApiKey>['username'];
realm: Required<estypes.SecurityApiKey>['realm'];
creation: Required<estypes.SecurityApiKey>['creation'];
metadata: Required<estypes.SecurityApiKey>['metadata'];
role_descriptors: Required<estypes.SecurityApiKey>['role_descriptors'];
}

// TODO: Remove this type when `@elastic/elasticsearch` has been updated.
export interface CrossClusterApiKeyAccess {
/**
* A list of indices permission entries for cross-cluster search.
*/
search?: CrossClusterApiKeySearch[];

/**
* A list of indices permission entries for cross-cluster replication.
*/
replication?: CrossClusterApiKeyReplication[];
}

// TODO: Remove this type when `@elastic/elasticsearch` has been updated.
type CrossClusterApiKeySearch = Pick<
estypes.SecurityIndicesPrivileges,
'names' | 'field_security' | 'query' | 'allow_restricted_indices'
>;

// TODO: Remove this type when `@elastic/elasticsearch` has been updated.
type CrossClusterApiKeyReplication = Pick<estypes.SecurityIndicesPrivileges, 'names'>;

export type ApiKeyRoleDescriptors = Record<string, estypes.SecurityRoleDescriptor>;

export interface ApiKeyToInvalidate {
id: string;
name: string;
}

export type ApiKeyRoleDescriptors = Record<string, Role['elasticsearch']>;
9 changes: 8 additions & 1 deletion x-pack/plugins/security/common/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
* 2.0.
*/

export type { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key';
export type {
ApiKey,
RestApiKey,
CrossClusterApiKey,
ApiKeyToInvalidate,
ApiKeyRoleDescriptors,
CrossClusterApiKeyAccess,
} from './api_key';
export type { User, EditUser, GetUserDisplayNameParams } from './user';
export type {
GetUserProfileResponse,
Expand Down
6 changes: 5 additions & 1 deletion x-pack/plugins/security/public/components/token_field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ export const TokenField: FunctionComponent<TokenFieldProps> = (props) => {
})}
className="euiFieldText euiFieldText--inGroup"
value={props.value}
style={{ fontFamily: euiThemeVars.euiCodeFontFamily, fontSize: euiThemeVars.euiFontSizeXS }}
style={{
fontFamily: euiThemeVars.euiCodeFontFamily,
fontSize: euiThemeVars.euiFontSizeXS,
backgroundColor: 'transparent',
}}
onFocus={(event) => event.currentTarget.select()}
readOnly
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
* 2.0.
*/

import type { PublicMethodsOf } from '@kbn/utility-types';

import type { APIKeysAPIClient } from './api_keys_api_client';

export const apiKeysAPIClientMock = {
create: () => ({
checkPrivileges: jest.fn(),
create: (): jest.Mocked<PublicMethodsOf<APIKeysAPIClient>> => ({
getApiKeys: jest.fn(),
invalidateApiKeys: jest.fn(),
createApiKey: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,6 @@ import { httpServiceMock } from '@kbn/core/public/mocks';
import { APIKeysAPIClient } from './api_keys_api_client';

describe('APIKeysAPIClient', () => {
it('checkPrivileges() queries correct endpoint', async () => {
const httpMock = httpServiceMock.createStartContract();

const mockResponse = Symbol('mockResponse');
httpMock.get.mockResolvedValue(mockResponse);

const apiClient = new APIKeysAPIClient(httpMock);

await expect(apiClient.checkPrivileges()).resolves.toBe(mockResponse);
expect(httpMock.get).toHaveBeenCalledTimes(1);
expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key/privileges');
});

it('getApiKeys() queries correct endpoint', async () => {
const httpMock = httpServiceMock.createStartContract();

Expand All @@ -33,23 +20,8 @@ describe('APIKeysAPIClient', () => {

await expect(apiClient.getApiKeys()).resolves.toBe(mockResponse);
expect(httpMock.get).toHaveBeenCalledTimes(1);
expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key', {
query: { isAdmin: false },
});
expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key');
httpMock.get.mockClear();

await expect(apiClient.getApiKeys(false)).resolves.toBe(mockResponse);
expect(httpMock.get).toHaveBeenCalledTimes(1);
expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key', {
query: { isAdmin: false },
});
httpMock.get.mockClear();

await expect(apiClient.getApiKeys(true)).resolves.toBe(mockResponse);
expect(httpMock.get).toHaveBeenCalledTimes(1);
expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key', {
query: { isAdmin: true },
});
});

it('invalidateApiKeys() queries correct endpoint', async () => {
Expand Down Expand Up @@ -92,7 +64,7 @@ describe('APIKeysAPIClient', () => {
httpMock.post.mockResolvedValue(mockResponse);

const apiClient = new APIKeysAPIClient(httpMock);
const mockAPIKeys = { name: 'name', expiration: '7d' };
const mockAPIKeys = { name: 'name', expiration: '7d' } as any;

await expect(apiClient.createApiKey(mockAPIKeys)).resolves.toBe(mockResponse);
expect(httpMock.post).toHaveBeenCalledTimes(1);
Expand All @@ -108,7 +80,7 @@ describe('APIKeysAPIClient', () => {
httpMock.put.mockResolvedValue(mockResponse);

const apiClient = new APIKeysAPIClient(httpMock);
const mockApiKeyUpdate = { id: 'test_id', metadata: {}, roles_descriptor: {} };
const mockApiKeyUpdate = { id: 'test_id', metadata: {}, roles_descriptor: {} } as any;

await expect(apiClient.updateApiKey(mockApiKeyUpdate)).resolves.toBe(mockResponse);
expect(httpMock.put).toHaveBeenCalledTimes(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,58 +7,29 @@

import type { HttpStart } from '@kbn/core/public';

import type { ApiKey, ApiKeyRoleDescriptors, ApiKeyToInvalidate } from '../../../common/model';
import type { ApiKeyToInvalidate } from '../../../common/model';
import type {
CreateAPIKeyParams,
CreateAPIKeyResult,
GetAPIKeysResult,
UpdateAPIKeyParams,
UpdateAPIKeyResult,
} from '../../../server/routes/api_keys';

export interface CheckPrivilegesResponse {
areApiKeysEnabled: boolean;
isAdmin: boolean;
canManage: boolean;
}
export type { CreateAPIKeyParams, CreateAPIKeyResult, UpdateAPIKeyParams, UpdateAPIKeyResult };

export interface InvalidateApiKeysResponse {
itemsInvalidated: ApiKeyToInvalidate[];
errors: any[];
}

export interface GetApiKeysResponse {
apiKeys: ApiKey[];
}

export interface CreateApiKeyRequest {
name: string;
expiration?: string;
role_descriptors?: ApiKeyRoleDescriptors;
metadata?: Record<string, any>;
}

export interface CreateApiKeyResponse {
id: string;
name: string;
expiration: number;
api_key: string;
}

export interface UpdateApiKeyRequest {
id: string;
role_descriptors?: ApiKeyRoleDescriptors;
metadata?: Record<string, any>;
}

export interface UpdateApiKeyResponse {
updated: boolean;
}

const apiKeysUrl = '/internal/security/api_key';

export class APIKeysAPIClient {
constructor(private readonly http: HttpStart) {}

public async checkPrivileges() {
return await this.http.get<CheckPrivilegesResponse>(`${apiKeysUrl}/privileges`);
}

public async getApiKeys(isAdmin = false) {
return await this.http.get<GetApiKeysResponse>(apiKeysUrl, { query: { isAdmin } });
public async getApiKeys() {
return await this.http.get<GetAPIKeysResult>(apiKeysUrl);
}

public async invalidateApiKeys(apiKeys: ApiKeyToInvalidate[], isAdmin = false) {
Expand All @@ -67,14 +38,14 @@ export class APIKeysAPIClient {
});
}

public async createApiKey(apiKey: CreateApiKeyRequest) {
return await this.http.post<CreateApiKeyResponse>(apiKeysUrl, {
public async createApiKey(apiKey: CreateAPIKeyParams) {
return await this.http.post<CreateAPIKeyResult>(apiKeysUrl, {
body: JSON.stringify(apiKey),
});
}

public async updateApiKey(apiKey: UpdateApiKeyRequest) {
return await this.http.put<UpdateApiKeyResponse>(apiKeysUrl, {
public async updateApiKey(apiKey: UpdateAPIKeyParams) {
return await this.http.put<UpdateAPIKeyResult>(apiKeysUrl, {
body: JSON.stringify(apiKey),
});
}
Expand Down
Loading

0 comments on commit 8c9411c

Please sign in to comment.