[Fleet][Agent tamper protection ] Split GET /uninstall_tokens API (#159944)

## Summary

> **Note**
> For testing: enable the `agentTamperProtectionEnabled` feature flag.

This PR modifies the quite new `GET /api/fleet/uninstall_tokens` API:
- `GET /api/fleet/uninstall_tokens`, returns token 'metadata' (i.e.
**uninstall token id**, policy ID and creation date) for the latest
token for every policy, without the token itself.
  - it is paginated (query params `page`, `perPage`),
  - and can be searched by partial policy ID (query param `policyId`).
  - this route is not used at the moment, will be used very soon
- `GET /api/fleet/uninstall_tokens/{id}` returns one decrypted token
identified by its ID
- ~`GET /api/fleet/agent_policies/{policyId}/uninstall_tokens`, returns
the decrypted token history for one policy~
  - ~this route is used by the `UninstallCommandFlyout`~
- this was added and then removed, because not a necessity at the
moment, and let's keep open all doors for agent tampering v2

### Todo - done  
`created_at` field was removed from the uninstall token saved object
mapping (21855ce37b320e1864c5b9db647ac2355158f91d), because it was
unused and messed up ordering by the saved object's own `created_at`
field.

This removal is not allowed, though, so this issue needs to be fixed.

**Update:** after a discussion with Kibana Core team, the `created_at`
field was removed in a separate PR which is merged in v8.9.0. Reason:
it's okay to use the SO's internal `created_at` field for sorting. Also,
the mapping will be released in v8.9.0 first, so it's okay to modify it
this time. The PR: https://github.com/elastic/kibana/pull/159985

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Gergő Ábrahám 2023-06-30 10:56:26 +02:00 committed by GitHub
parent a5620cdb98
commit c9b6054cbe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 957 additions and 484 deletions

View file

@ -110,6 +110,7 @@ const STANDARD_LIST_TYPES = [
'epm-packages-assets',
'fleet-preconfiguration-deletion-record',
'fleet-fleet-server-host',
'fleet-uninstall-tokens',
];
/**

View file

@ -101,7 +101,14 @@ export const getHttp = (basepath = BASE_PATH) => {
};
}
if (path.match('/api/fleet/uninstall_tokens')) {
if (path.match('/api/fleet/uninstall_tokens/token-id-13')) {
return {
item: { token: '123-456-789' },
page: 1,
perPage: 20,
total: 1,
};
} else if (path.match('/api/fleet/uninstall_tokens')) {
if (options.query?.policyId === 'missing-policy') {
return {
items: [],
@ -111,7 +118,7 @@ export const getHttp = (basepath = BASE_PATH) => {
};
} else {
return {
items: [{ token: '123-456-789' }],
items: [{ id: 'token-id-13' }],
page: 1,
perPage: 20,
total: 1,

View file

@ -171,6 +171,7 @@ export const ENROLLMENT_API_KEY_ROUTES = {
export const UNINSTALL_TOKEN_ROUTES = {
LIST_PATTERN: `${API_ROOT}/uninstall_tokens`,
INFO_PATTERN: `${API_ROOT}/uninstall_tokens/{uninstallTokenId}`,
};
// Agents setup API routes

View file

@ -22,3 +22,4 @@ export class MessageSigningError extends FleetError {}
export class FleetActionsError extends FleetError {}
export class FleetActionsClientError extends FleetError {}
export class UninstallTokenError extends FleetError {}

View file

@ -286,6 +286,8 @@ export const enrollmentAPIKeyRouteService = {
export const uninstallTokensRouteService = {
getListPath: () => UNINSTALL_TOKEN_ROUTES.LIST_PATTERN,
getInfoPath: (uninstallTokenId: string) =>
UNINSTALL_TOKEN_ROUTES.INFO_PATTERN.replace('{uninstallTokenId}', uninstallTokenId),
};
export const setupRouteService = {

View file

@ -6,7 +6,10 @@
*/
export interface UninstallToken {
id: string;
policy_id: string;
token: string;
created_at?: string;
created_at: string;
}
export type UninstallTokenMetadata = Omit<UninstallToken, 'token'>;

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import type { UninstallToken } from '../models/uninstall_token';
import type { UninstallToken, UninstallTokenMetadata } from '../models/uninstall_token';
import type { ListResult } from './common';
export interface GetUninstallTokensRequest {
export interface GetUninstallTokensMetadataRequest {
query: {
policyId?: string;
perPage?: number;
@ -17,4 +17,14 @@ export interface GetUninstallTokensRequest {
};
}
export type GetUninstallTokensResponse = ListResult<UninstallToken>;
export type GetUninstallTokensMetadataResponse = ListResult<UninstallTokenMetadata>;
export interface GetUninstallTokenRequest {
params: {
uninstallTokenId: string;
};
}
export interface GetUninstallTokenResponse {
item: UninstallToken;
}

View file

@ -9,11 +9,22 @@ import React from 'react';
import type { UseRequestResponse } from '@kbn/es-ui-shared-plugin/public';
import type { GetUninstallTokensResponse } from '../../../common/types/rest_spec/uninstall_token';
import type {
UninstallToken,
UninstallTokenMetadata,
} from '../../../common/types/models/uninstall_token';
import type {
GetUninstallTokensMetadataResponse,
GetUninstallTokenResponse,
} from '../../../common/types/rest_spec/uninstall_token';
import { createFleetTestRendererMock } from '../../mock';
import { useGetUninstallTokens } from '../../hooks/use_request/uninstall_tokens';
import {
useGetUninstallTokens,
useGetUninstallToken,
} from '../../hooks/use_request/uninstall_tokens';
import type { RequestError } from '../../hooks';
@ -21,13 +32,29 @@ import type { UninstallCommandFlyoutProps } from './uninstall_command_flyout';
import { UninstallCommandFlyout } from './uninstall_command_flyout';
jest.mock('../../hooks/use_request/uninstall_tokens', () => ({
useGetUninstallToken: jest.fn(),
useGetUninstallTokens: jest.fn(),
}));
type MockReturnType = Partial<UseRequestResponse<GetUninstallTokensResponse, RequestError>>;
type MockResponseType<DataType> = Pick<
UseRequestResponse<DataType, RequestError>,
'data' | 'error' | 'isLoading'
>;
describe('UninstallCommandFlyout', () => {
const uninstallTokenMetadataFixture: UninstallTokenMetadata = {
id: 'id-1',
policy_id: 'policy_id',
created_at: '2023-06-19T08:47:31.457Z',
};
const uninstallTokenFixture: UninstallToken = {
...uninstallTokenMetadataFixture,
token: '123456789',
};
const useGetUninstallTokensMock = useGetUninstallTokens as jest.Mock;
const useGetUninstallTokenMock = useGetUninstallToken as jest.Mock;
const render = (props: Partial<UninstallCommandFlyoutProps> = {}) => {
const renderer = createFleetTestRendererMock();
@ -38,20 +65,26 @@ describe('UninstallCommandFlyout', () => {
};
beforeEach(() => {
const response: GetUninstallTokensResponse = {
items: [{ policy_id: 'policy_id', token: '123456789' }],
total: 1,
page: 1,
perPage: 20,
};
const mockReturn: MockReturnType = {
const getTokensResponseFixture: MockResponseType<GetUninstallTokensMetadataResponse> = {
isLoading: false,
error: null,
data: response,
data: {
items: [uninstallTokenMetadataFixture],
total: 1,
page: 1,
perPage: 20,
},
};
useGetUninstallTokensMock.mockReturnValue(getTokensResponseFixture);
useGetUninstallTokensMock.mockReturnValue(mockReturn);
const getTokenResponseFixture: MockResponseType<GetUninstallTokenResponse> = {
isLoading: false,
error: null,
data: {
item: uninstallTokenFixture,
},
};
useGetUninstallTokenMock.mockReturnValue(getTokenResponseFixture);
});
describe('uninstall command targets', () => {
@ -72,9 +105,9 @@ describe('UninstallCommandFlyout', () => {
});
});
describe('when fetching the tokens is successful', () => {
describe('when fetching the token is successful', () => {
it('shows loading spinner while fetching', () => {
const mockReturn: MockReturnType = {
const mockReturn: MockResponseType<GetUninstallTokensMetadataResponse> = {
isLoading: true,
error: null,
data: null,
@ -132,9 +165,9 @@ describe('UninstallCommandFlyout', () => {
});
});
describe('when fetching the tokens is unsuccessful', () => {
describe('when fetching the token metadata is unsuccessful', () => {
it('shows error message when fetching returns an error', () => {
const mockReturn: MockReturnType = {
const mockReturn: MockResponseType<GetUninstallTokensMetadataResponse> = {
isLoading: false,
error: new Error('received error message'),
data: null,
@ -150,7 +183,7 @@ describe('UninstallCommandFlyout', () => {
});
it('shows "Unknown error" error message when token is missing from response', () => {
const mockReturn: MockReturnType = {
const mockReturn: MockResponseType<GetUninstallTokensMetadataResponse> = {
isLoading: false,
error: null,
data: null,
@ -165,4 +198,38 @@ describe('UninstallCommandFlyout', () => {
expect(renderResult.queryByText(/Unknown error/)).toBeInTheDocument();
});
});
describe('when fetching the decrypted token is unsuccessful', () => {
it('shows error message when fetching returns an error', () => {
const mockReturn: MockResponseType<GetUninstallTokenResponse> = {
isLoading: false,
error: new Error('received error message'),
data: null,
};
useGetUninstallTokenMock.mockReturnValue(mockReturn);
const renderResult = render();
expect(renderResult.queryByTestId('loadingSpinner')).not.toBeInTheDocument();
expect(renderResult.queryByText(/Unable to fetch uninstall token/)).toBeInTheDocument();
expect(renderResult.queryByText(/received error message/)).toBeInTheDocument();
});
it('shows "Unknown error" error message when token is missing from response', () => {
const mockReturn: MockResponseType<GetUninstallTokenResponse> = {
isLoading: false,
error: null,
data: null,
};
useGetUninstallTokenMock.mockReturnValue(mockReturn);
const renderResult = render();
expect(renderResult.queryByTestId('loadingSpinner')).not.toBeInTheDocument();
expect(renderResult.queryByText(/Unable to fetch uninstall token/)).toBeInTheDocument();
expect(renderResult.queryByText(/Unknown error/)).toBeInTheDocument();
});
});
});

View file

@ -19,13 +19,17 @@ import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import type { RequestError } from '../../hooks';
import { useStartServices } from '../../hooks';
import { useGetUninstallTokens } from '../../hooks/use_request/uninstall_tokens';
import { Error } from '../error';
import { Loading } from '../loading';
import {
useGetUninstallToken,
useGetUninstallTokens,
} from '../../hooks/use_request/uninstall_tokens';
import { UninstallCommandsPerPlatform } from './uninstall_commands_per_platform';
import type { UninstallCommandTarget } from './types';
@ -82,35 +86,47 @@ const UninstallEndpointDescription = () => (
</>
);
const UninstallCommands = ({ policyId }: { policyId: string }) => {
const { data, error, isLoading } = useGetUninstallTokens({ policyId });
if (isLoading) {
return <Loading size="l" />;
}
const token: string | null = data?.items?.[0]?.token ?? null;
if (error || !token) {
return (
<Error
title={
<FormattedMessage
id="xpack.fleet.agentUninstallCommandFlyout.errorFetchingToken"
defaultMessage="Unable to fetch uninstall token"
/>
}
error={
error ??
i18n.translate('xpack.fleet.agentUninstallCommandFlyout.unknownError', {
defaultMessage: 'Unknown error',
})
}
const ErrorFetchingUninstallToken = ({ error }: { error: RequestError | null }) => (
<Error
title={
<FormattedMessage
id="xpack.fleet.agentUninstallCommandFlyout.errorFetchingToken"
defaultMessage="Unable to fetch uninstall token"
/>
);
}
}
error={
error ??
i18n.translate('xpack.fleet.agentUninstallCommandFlyout.unknownError', {
defaultMessage: 'Unknown error',
})
}
/>
);
return <UninstallCommandsPerPlatform token={token} />;
const UninstallCommandsByTokenId = ({ uninstallTokenId }: { uninstallTokenId: string }) => {
const { isLoading, error, data } = useGetUninstallToken(uninstallTokenId);
const token = data?.item.token;
return isLoading ? (
<Loading size="l" />
) : error || !token ? (
<ErrorFetchingUninstallToken error={error} />
) : (
<UninstallCommandsPerPlatform token={token} />
);
};
const UninstallCommandsByPolicyId = ({ policyId }: { policyId: string }) => {
const { isLoading, error, data } = useGetUninstallTokens({ policyId });
const tokenId = data?.items?.[0]?.id;
return isLoading ? (
<Loading size="l" />
) : error || !tokenId ? (
<ErrorFetchingUninstallToken error={error} />
) : (
<UninstallCommandsByTokenId uninstallTokenId={tokenId} />
);
};
export interface UninstallCommandFlyoutProps {
@ -144,7 +160,7 @@ export const UninstallCommandFlyout: React.FunctionComponent<UninstallCommandFly
<EuiSpacer size="l" />
<UninstallCommands policyId={policyId} />
<UninstallCommandsByPolicyId policyId={policyId} />
<EuiSpacer size="l" />

View file

@ -8,8 +8,9 @@
import { uninstallTokensRouteService } from '../../../common/services';
import type {
GetUninstallTokensRequest,
GetUninstallTokensResponse,
GetUninstallTokensMetadataRequest,
GetUninstallTokensMetadataResponse,
GetUninstallTokenResponse,
} from '../../../common/types/rest_spec/uninstall_token';
import { useRequest } from './use_request';
@ -18,16 +19,22 @@ export const useGetUninstallTokens = ({
policyId,
page,
perPage,
}: GetUninstallTokensRequest['query'] = {}) => {
const query: GetUninstallTokensRequest['query'] = {
}: GetUninstallTokensMetadataRequest['query'] = {}) => {
const query: GetUninstallTokensMetadataRequest['query'] = {
policyId,
page,
perPage,
};
return useRequest<GetUninstallTokensResponse>({
return useRequest<GetUninstallTokensMetadataResponse>({
method: 'get',
path: uninstallTokensRouteService.getListPath(),
query,
});
};
export const useGetUninstallToken = (uninstallTokenId: string) =>
useRequest<GetUninstallTokenResponse>({
method: 'get',
path: uninstallTokensRouteService.getInfoPath(uninstallTokenId),
});

View file

@ -15,6 +15,8 @@ import type {
} from '@kbn/core/server';
import type { KibanaRequest } from '@kbn/core/server';
import { UninstallTokenError } from '../../common/errors';
import { appContextService } from '../services';
import {
@ -82,6 +84,9 @@ const getHTTPResponseCode = (error: FleetError): number => {
if (error instanceof PackagePolicyNameExistsError) {
return 409; // Conflict
}
if (error instanceof UninstallTokenError) {
return 500; // Internal Error
}
return 400; // Bad Request
};

View file

@ -186,10 +186,8 @@ export function createMessageSigningServiceMock(): MessageSigningServiceInterfac
export function createUninstallTokenServiceMock(): UninstallTokenServiceInterface {
return {
getTokenForPolicyId: jest.fn(),
getTokensForPolicyIds: jest.fn(),
getAllTokens: jest.fn(),
findTokensForPartialPolicyId: jest.fn(),
getToken: jest.fn(),
getTokenMetadata: jest.fn(),
getHashedTokenForPolicyId: jest.fn(),
getHashedTokensForPolicyIds: jest.fn(),
getAllHashedTokens: jest.fn(),

View file

@ -12,46 +12,41 @@ import { httpServerMock, coreMock } from '@kbn/core/server/mocks';
import type { RouterMock } from '@kbn/core-http-router-server-mocks';
import { mockRouter } from '@kbn/core-http-router-server-mocks';
import type { GetUninstallTokensResponse } from '../../../common/types/rest_spec/uninstall_token';
import type {
UninstallToken,
UninstallTokenMetadata,
} from '../../../common/types/models/uninstall_token';
import type {
GetUninstallTokenRequest,
GetUninstallTokensMetadataResponse,
} from '../../../common/types/rest_spec/uninstall_token';
import type { FleetRequestHandlerContext } from '../..';
import type { MockedFleetAppContext } from '../../mocks';
import { createAppContextStartContractMock, xpackMocks } from '../../mocks';
import { appContextService } from '../../services';
import type { GetUninstallTokensRequestSchema } from '../../types/rest_spec/uninstall_token';
import type {
GetUninstallTokenRequestSchema,
GetUninstallTokensMetadataRequestSchema,
} from '../../types/rest_spec/uninstall_token';
import { registerRoutes } from '.';
import { getUninstallTokensHandler } from './handlers';
import { getUninstallTokenHandler, getUninstallTokensMetadataHandler } from './handlers';
describe('getUninstallTokensHandler', () => {
describe('uninstall token handlers', () => {
let context: FleetRequestHandlerContext;
let request: KibanaRequest<unknown, TypeOf<typeof GetUninstallTokensRequestSchema.query>>;
let response: ReturnType<typeof httpServerMock.createResponseFactory>;
let appContextStartContractMock: MockedFleetAppContext;
let getAllTokensMock: jest.Mock;
const uninstallTokensResponseFixture: GetUninstallTokensResponse = {
items: [
{ policy_id: 'policy-id-1', token: '123456' },
{ policy_id: 'policy-id-2', token: 'abcdef' },
{ policy_id: 'policy-id-3', token: '9876543210' },
],
total: 3,
page: 1,
perPage: 20,
};
beforeEach(async () => {
context = coreMock.createCustomRequestHandlerContext(xpackMocks.createRequestHandlerContext());
response = httpServerMock.createResponseFactory();
request = httpServerMock.createKibanaRequest();
appContextStartContractMock = createAppContextStartContractMock();
appContextService.start(appContextStartContractMock);
getAllTokensMock = appContextService.getUninstallTokenService()?.getAllTokens as jest.Mock;
});
afterEach(async () => {
@ -59,40 +54,132 @@ describe('getUninstallTokensHandler', () => {
appContextService.stop();
});
it('should return uninstall tokens for all policies', async () => {
getAllTokensMock.mockResolvedValue(uninstallTokensResponseFixture);
describe('getUninstallTokensMetadataHandler', () => {
const uninstallTokensFixture: UninstallTokenMetadata[] = [
{ id: 'id-1', policy_id: 'policy-id-1', created_at: '2023-06-15T16:46:48.274Z' },
{ id: 'id-2', policy_id: 'policy-id-2', created_at: '2023-06-15T16:46:48.274Z' },
{ id: 'id-3', policy_id: 'policy-id-3', created_at: '2023-06-15T16:46:48.274Z' },
];
await getUninstallTokensHandler(context, request, response);
const uninstallTokensResponseFixture: GetUninstallTokensMetadataResponse = {
items: uninstallTokensFixture,
total: 3,
page: 1,
perPage: 20,
};
expect(response.ok).toHaveBeenCalledWith({
body: uninstallTokensResponseFixture,
let getTokenMetadataMock: jest.Mock;
let request: KibanaRequest<
unknown,
TypeOf<typeof GetUninstallTokensMetadataRequestSchema.query>
>;
beforeEach(() => {
const uninstallTokenService = appContextService.getUninstallTokenService()!;
getTokenMetadataMock = uninstallTokenService.getTokenMetadata as jest.Mock;
request = httpServerMock.createKibanaRequest();
});
it('should return uninstall tokens for all policies', async () => {
getTokenMetadataMock.mockResolvedValue(uninstallTokensResponseFixture);
await getUninstallTokensMetadataHandler(context, request, response);
expect(response.ok).toHaveBeenCalledWith({
body: uninstallTokensResponseFixture,
});
});
it('should return internal error when uninstallTokenService is unavailable', async () => {
appContextService.stop();
appContextService.start({
...appContextStartContractMock,
// @ts-expect-error
uninstallTokenService: undefined,
});
await getUninstallTokensMetadataHandler(context, request, response);
expect(response.customError).toHaveBeenCalledWith({
statusCode: 500,
body: { message: 'Uninstall Token Service is unavailable.' },
});
});
it('should return internal error when uninstallTokenService throws error', async () => {
getTokenMetadataMock.mockRejectedValue(Error('something happened'));
await getUninstallTokensMetadataHandler(context, request, response);
expect(response.customError).toHaveBeenCalledWith({
statusCode: 500,
body: { message: 'something happened' },
});
});
});
it('should return internal error when uninstallTokenService is unavailable', async () => {
appContextService.stop();
appContextService.start({
...appContextStartContractMock,
// @ts-expect-error
uninstallTokenService: undefined,
describe('getUninstallTokenHandler', () => {
const uninstallTokenFixture: UninstallToken = {
id: 'id-1',
policy_id: 'policy-id-1',
created_at: '2023-06-15T16:46:48.274Z',
token: '123456789',
};
let getTokenMock: jest.Mock;
let request: KibanaRequest<TypeOf<typeof GetUninstallTokenRequestSchema.params>>;
beforeEach(() => {
const uninstallTokenService = appContextService.getUninstallTokenService()!;
getTokenMock = uninstallTokenService.getToken as jest.Mock;
const requestOptions: GetUninstallTokenRequest = {
params: {
uninstallTokenId: uninstallTokenFixture.id,
},
};
request = httpServerMock.createKibanaRequest(requestOptions);
});
await getUninstallTokensHandler(context, request, response);
it('should return requested uninstall token', async () => {
getTokenMock.mockResolvedValue(uninstallTokenFixture);
expect(response.customError).toHaveBeenCalledWith({
statusCode: 500,
body: { message: 'Uninstall Token Service is unavailable.' },
await getUninstallTokenHandler(context, request, response);
expect(getTokenMock).toHaveBeenCalledWith(uninstallTokenFixture.id);
expect(response.ok).toHaveBeenCalledWith({
body: {
item: uninstallTokenFixture,
},
});
});
});
it('should return internal error when uninstallTokenService throws error', async () => {
getAllTokensMock.mockRejectedValue(Error('something happened'));
it('should return internal error when uninstallTokenService is unavailable', async () => {
appContextService.stop();
appContextService.start({
...appContextStartContractMock,
// @ts-expect-error
uninstallTokenService: undefined,
});
await getUninstallTokensHandler(context, request, response);
await getUninstallTokenHandler(context, request, response);
expect(response.customError).toHaveBeenCalledWith({
statusCode: 500,
body: { message: 'something happened' },
expect(response.customError).toHaveBeenCalledWith({
statusCode: 500,
body: { message: 'Uninstall Token Service is unavailable.' },
});
});
it('should return internal error when uninstallTokenService throws error', async () => {
getTokenMock.mockRejectedValue(Error('something happened'));
await getUninstallTokenHandler(context, request, response);
expect(response.customError).toHaveBeenCalledWith({
statusCode: 500,
body: { message: 'something happened' },
});
});
});
@ -104,15 +191,19 @@ describe('getUninstallTokensHandler', () => {
router = mockRouter.create();
});
it('should register handler if feature flag is enabled', () => {
it('should register handlers if feature flag is enabled', () => {
config = { enableExperimental: ['agentTamperProtectionEnabled'] };
registerRoutes(router, config);
expect(router.get).toHaveBeenCalledWith(expect.any(Object), getUninstallTokensHandler);
expect(router.get).toHaveBeenCalledWith(
expect.any(Object),
getUninstallTokensMetadataHandler
);
expect(router.get).toHaveBeenCalledWith(expect.any(Object), getUninstallTokenHandler);
});
it('should NOT register handler if feature flag is disabled', async () => {
it('should NOT register handlers if feature flag is disabled', async () => {
config = { enableExperimental: [] };
registerRoutes(router, config);

View file

@ -6,34 +6,64 @@
*/
import type { TypeOf } from '@kbn/config-schema';
import type { CustomHttpResponseOptions, ResponseError } from '@kbn/core-http-server';
import { appContextService } from '../../services';
import type { FleetRequestHandler } from '../../types';
import type { GetUninstallTokensResponse } from '../../../common/types/rest_spec/uninstall_token';
import type { GetUninstallTokensRequestSchema } from '../../types/rest_spec/uninstall_token';
import type {
GetUninstallTokensMetadataRequestSchema,
GetUninstallTokenRequestSchema,
} from '../../types/rest_spec/uninstall_token';
import { defaultFleetErrorHandler } from '../../errors';
import type { GetUninstallTokenResponse } from '../../../common/types/rest_spec/uninstall_token';
export const getUninstallTokensHandler: FleetRequestHandler<
const UNINSTALL_TOKEN_SERVICE_UNAVAILABLE_ERROR: CustomHttpResponseOptions<ResponseError> = {
statusCode: 500,
body: { message: 'Uninstall Token Service is unavailable.' },
};
export const getUninstallTokensMetadataHandler: FleetRequestHandler<
unknown,
TypeOf<typeof GetUninstallTokensRequestSchema.query>
TypeOf<typeof GetUninstallTokensMetadataRequestSchema.query>
> = async (context, request, response) => {
const uninstallTokenService = appContextService.getUninstallTokenService();
if (!uninstallTokenService) {
return response.customError({
statusCode: 500,
body: { message: 'Uninstall Token Service is unavailable.' },
});
return response.customError(UNINSTALL_TOKEN_SERVICE_UNAVAILABLE_ERROR);
}
try {
const { page = 1, perPage = 20, policyId } = request.query;
let body: GetUninstallTokensResponse;
if (policyId) {
body = await uninstallTokenService.findTokensForPartialPolicyId(policyId, page, perPage);
} else {
body = await uninstallTokenService.getAllTokens(page, perPage);
}
const body = await uninstallTokenService.getTokenMetadata(policyId?.trim(), page, perPage);
return response.ok({ body });
} catch (error) {
return defaultFleetErrorHandler({ error, response });
}
};
export const getUninstallTokenHandler: FleetRequestHandler<
TypeOf<typeof GetUninstallTokenRequestSchema.params>
> = async (context, request, response) => {
const uninstallTokenService = appContextService.getUninstallTokenService();
if (!uninstallTokenService) {
return response.customError(UNINSTALL_TOKEN_SERVICE_UNAVAILABLE_ERROR);
}
try {
const { uninstallTokenId } = request.params;
const token = await uninstallTokenService.getToken(uninstallTokenId);
if (token === null) {
return response.notFound({
body: { message: `Uninstall Token not found with id ${uninstallTokenId}` },
});
}
const body: GetUninstallTokenResponse = {
item: token,
};
return response.ok({ body });
} catch (error) {

View file

@ -8,10 +8,13 @@ import { UNINSTALL_TOKEN_ROUTES } from '../../../common/constants';
import type { FleetConfigType } from '../../config';
import type { FleetAuthzRouter } from '../../services/security';
import { GetUninstallTokensRequestSchema } from '../../types/rest_spec/uninstall_token';
import {
GetUninstallTokenRequestSchema,
GetUninstallTokensMetadataRequestSchema,
} from '../../types/rest_spec/uninstall_token';
import { parseExperimentalConfigValue } from '../../../common/experimental_features';
import { getUninstallTokensHandler } from './handlers';
import { getUninstallTokenHandler, getUninstallTokensMetadataHandler } from './handlers';
export const registerRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => {
const experimentalFeatures = parseExperimentalConfigValue(config.enableExperimental);
@ -20,12 +23,23 @@ export const registerRoutes = (router: FleetAuthzRouter, config: FleetConfigType
router.get(
{
path: UNINSTALL_TOKEN_ROUTES.LIST_PATTERN,
validate: GetUninstallTokensRequestSchema,
validate: GetUninstallTokensMetadataRequestSchema,
fleetAuthz: {
fleet: { all: true },
},
},
getUninstallTokensHandler
getUninstallTokensMetadataHandler
);
router.get(
{
path: UNINSTALL_TOKEN_ROUTES.INFO_PATTERN,
validate: GetUninstallTokenRequestSchema,
fleetAuthz: {
fleet: { all: true },
},
},
getUninstallTokenHandler
);
}
};

View file

@ -8,11 +8,17 @@
import { createHash } from 'crypto';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { SavedObjectsClientContract } from '@kbn/core/server';
import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import type { UninstallToken } from '../../../../common/types/models/uninstall_token';
import { SO_SEARCH_LIMIT } from '../../../../common';
import type {
UninstallToken,
UninstallTokenMetadata,
} from '../../../../common/types/models/uninstall_token';
import { UNINSTALL_TOKENS_SAVED_OBJECT_TYPE } from '../../../constants';
import { createAppContextStartContractMock, type MockedFleetAppContext } from '../../../mocks';
@ -22,6 +28,7 @@ import { agentPolicyService } from '../../agent_policy';
import { UninstallTokenService, type UninstallTokenServiceInterface } from '.';
describe('UninstallTokenService', () => {
const now = new Date().toISOString();
const aDayAgo = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
let soClientMock: jest.Mocked<SavedObjectsClientContract>;
@ -38,6 +45,7 @@ describe('UninstallTokenService', () => {
policy_id: 'test-policy-id',
token: 'test-token',
},
created_at: now,
}
: {
id: 'test-so-id',
@ -45,6 +53,7 @@ describe('UninstallTokenService', () => {
policy_id: 'test-policy-id',
token_plain: 'test-token-plain',
},
created_at: now,
};
}
@ -80,6 +89,10 @@ describe('UninstallTokenService', () => {
{
_id: defaultSO.id,
...defaultSO,
_source: {
[UNINSTALL_TOKENS_SAVED_OBJECT_TYPE]: defaultSO.attributes,
created_at: defaultSO.created_at,
},
},
],
},
@ -94,6 +107,7 @@ describe('UninstallTokenService', () => {
_id: defaultSO2.id,
...defaultSO2,
_source: {
[UNINSTALL_TOKENS_SAVED_OBJECT_TYPE]: defaultSO2.attributes,
created_at: defaultSO2.created_at,
},
},
@ -185,52 +199,74 @@ describe('UninstallTokenService', () => {
});
describe('get uninstall tokens', () => {
it('can correctly getTokenForPolicyId', async () => {
const so = getDefaultSO(canEncrypt);
const token = await uninstallTokenService.getTokenForPolicyId(so.attributes.policy_id);
expect(token).toEqual({
policy_id: so.attributes.policy_id,
token: getToken(so, canEncrypt),
} as UninstallToken);
});
describe('getToken', () => {
it('can correctly get one token', async () => {
const so = getDefaultSO(canEncrypt);
mockCreatePointInTimeFinderAsInternalUser([so]);
it('can correctly getTokensForPolicyIds', async () => {
const so = getDefaultSO(canEncrypt);
const so2 = getDefaultSO2(canEncrypt);
const token = await uninstallTokenService.getToken(so.id);
const tokensMap = await uninstallTokenService.getTokensForPolicyIds([
so.attributes.policy_id,
so2.attributes.policy_id,
]);
expect(tokensMap).toEqual([
{
const expectedItem: UninstallToken = {
id: so.id,
policy_id: so.attributes.policy_id,
token: getToken(so, canEncrypt),
},
{
policy_id: so2.attributes.policy_id,
token: getToken(so2, canEncrypt),
created_at: aDayAgo,
},
] as UninstallToken[]);
created_at: so.created_at,
};
expect(token).toEqual(expectedItem);
expect(esoClientMock.createPointInTimeFinderDecryptedAsInternalUser).toHaveBeenCalledWith(
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
filter: `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}:${so.id}"`,
perPage: SO_SEARCH_LIMIT,
}
);
});
});
it('can correctly getAllTokens', async () => {
const so = getDefaultSO(canEncrypt);
const so2 = getDefaultSO2(canEncrypt);
describe('getTokenMetadata', () => {
it('can correctly get token metadata', async () => {
const so = getDefaultSO(canEncrypt);
const so2 = getDefaultSO2(canEncrypt);
const tokensMap = (await uninstallTokenService.getAllTokens()).items;
expect(tokensMap).toEqual([
{
policy_id: so.attributes.policy_id,
token: getToken(so, canEncrypt),
},
{
policy_id: so2.attributes.policy_id,
token: getToken(so2, canEncrypt),
created_at: aDayAgo,
},
] as UninstallToken[]);
const actualItems = (await uninstallTokenService.getTokenMetadata()).items;
const expectedItems: UninstallTokenMetadata[] = [
{
id: so.id,
policy_id: so.attributes.policy_id,
created_at: so.created_at,
},
{
id: so2.id,
policy_id: so2.attributes.policy_id,
created_at: so2.created_at,
},
];
expect(actualItems).toEqual(expectedItems);
});
it('should throw error if created_at is missing', async () => {
const defaultBuckets = getDefaultBuckets(canEncrypt);
defaultBuckets[0].latest.hits.hits[0]._source.created_at = '';
mockCreatePointInTimeFinder(canEncrypt, defaultBuckets);
await expect(uninstallTokenService.getTokenMetadata()).rejects.toThrowError(
'Uninstall Token is missing creation date.'
);
});
it('should throw error if policy_id is missing', async () => {
const defaultBuckets = getDefaultBuckets(canEncrypt);
defaultBuckets[0].latest.hits.hits[0]._source[
UNINSTALL_TOKENS_SAVED_OBJECT_TYPE
].policy_id = '';
mockCreatePointInTimeFinder(canEncrypt, defaultBuckets);
await expect(uninstallTokenService.getTokenMetadata()).rejects.toThrowError(
'Uninstall Token is missing policy ID.'
);
});
});
});

View file

@ -18,6 +18,7 @@ import type {
import type {
AggregationsMultiBucketAggregateBase,
AggregationsTopHitsAggregate,
SearchHit,
} from '@elastic/elasticsearch/lib/api/types';
import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server';
import type { KibanaRequest } from '@kbn/core-http-server';
@ -26,9 +27,14 @@ import { asyncForEach } from '@kbn/std';
import type { AggregationsTermsInclude } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { GetUninstallTokensResponse } from '../../../../common/types/rest_spec/uninstall_token';
import { UninstallTokenError } from '../../../../common/errors';
import type { UninstallToken } from '../../../../common/types/models/uninstall_token';
import type { GetUninstallTokensMetadataResponse } from '../../../../common/types/rest_spec/uninstall_token';
import type {
UninstallToken,
UninstallTokenMetadata,
} from '../../../../common/types/models/uninstall_token';
import { UNINSTALL_TOKENS_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../../constants';
import { appContextService } from '../../app_context';
@ -50,30 +56,83 @@ interface UninstallTokenSOAggregation {
}
export interface UninstallTokenServiceInterface {
getTokenForPolicyId(policyId: string): Promise<UninstallToken | null>;
/**
* Get uninstall token based on its id.
*
* @param id
* @returns uninstall token if found, null if not found
*/
getToken(id: string): Promise<UninstallToken | null>;
getTokensForPolicyIds(policyIds: string[]): Promise<UninstallToken[]>;
findTokensForPartialPolicyId(
searchString: string,
/**
* Get uninstall token metadata, optionally filtering by partial policyID, paginated
*
* @param policyIdFilter a string for partial matching the policyId
* @param page
* @param perPage
* @returns Uninstall Tokens Metadata Response
*/
getTokenMetadata(
policyIdFilter?: string,
page?: number,
perPage?: number
): Promise<GetUninstallTokensResponse>;
getAllTokens(page?: number, perPage?: number): Promise<GetUninstallTokensResponse>;
): Promise<GetUninstallTokensMetadataResponse>;
/**
* Get hashed uninstall token for given policy id
*
* @param policyId agent policy id
* @returns hashedToken
*/
getHashedTokenForPolicyId(policyId: string): Promise<string>;
/**
* Get hashed uninstall tokens for given policy ids
*
* @param policyIds agent policy ids
* @returns Record<policyId, hashedToken>
*/
getHashedTokensForPolicyIds(policyIds?: string[]): Promise<Record<string, string>>;
/**
* Get hashed uninstall token for all policies
*
* @returns Record<policyId, hashedToken>
*/
getAllHashedTokens(): Promise<Record<string, string>>;
/**
* Generate uninstall token for given policy id
* Will not create a new token if one already exists for a given policy unless force: true is used
*
* @param policyId agent policy id
* @param force generate a new token even if one already exists
* @returns hashedToken
*/
generateTokenForPolicyId(policyId: string, force?: boolean): Promise<string>;
/**
* Generate uninstall tokens for given policy ids
* Will not create a new token if one already exists for a given policy unless force: true is used
*
* @param policyIds agent policy ids
* @param force generate a new token even if one already exists
* @returns Record<policyId, hashedToken>
*/
generateTokensForPolicyIds(policyIds: string[], force?: boolean): Promise<Record<string, string>>;
/**
* Generate uninstall tokens all policies
* Will not create a new token if one already exists for a given policy unless force: true is used
*
* @param force generate a new token even if one already exists
* @returns Record<policyId, hashedToken>
*/
generateTokensForAllPolicies(force?: boolean): Promise<Record<string, string>>;
/**
* If encryption is available, checks for any plain text uninstall tokens and encrypts them
*/
encryptTokens(): Promise<void>;
}
@ -82,62 +141,97 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
constructor(private esoClient: EncryptedSavedObjectsClient) {}
/**
* gets uninstall token for given policy id
*
* @param policyId agent policy id
* @returns uninstall token if found
*/
public async getTokenForPolicyId(policyId: string): Promise<UninstallToken | null> {
return (await this.getTokensByIncludeFilter({ include: policyId })).items[0] ?? null;
public async getToken(id: string): Promise<UninstallToken | null> {
const filter = `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}:${id}"`;
const uninstallTokens = await this.getDecryptedTokens({ filter });
return uninstallTokens.length === 1 ? uninstallTokens[0] : null;
}
/**
* gets uninstall tokens for given policy ids
*
* @param policyIds agent policy ids
* @returns array of UninstallToken objects
*/
public async getTokensForPolicyIds(policyIds: string[]): Promise<UninstallToken[]> {
return (await this.getTokensByIncludeFilter({ include: policyIds })).items;
}
/**
* gets uninstall token for given policy id, paginated
*
* @param searchString a string for partial matching the policyId
* @param page
* @param perPage
* @param policyId agent policy id
* @returns GetUninstallTokensResponse
*/
public async findTokensForPartialPolicyId(
searchString: string,
page: number = 1,
perPage: number = 20
): Promise<GetUninstallTokensResponse> {
return await this.getTokensByIncludeFilter({ include: `.*${searchString}.*`, page, perPage });
}
/**
* gets uninstall tokens for all policies, optionally paginated or returns all tokens
* @param page
* @param perPage
* @returns GetUninstallTokensResponse
*/
public async getAllTokens(page?: number, perPage?: number): Promise<GetUninstallTokensResponse> {
return this.getTokensByIncludeFilter({ perPage, page });
}
private async getTokensByIncludeFilter({
public async getTokenMetadata(
policyIdFilter?: string,
page = 1,
perPage = SO_SEARCH_LIMIT,
include,
}: {
include?: AggregationsTermsInclude;
perPage?: number;
page?: number;
}): Promise<GetUninstallTokensResponse> {
perPage = 20
): Promise<GetUninstallTokensMetadataResponse> {
const includeFilter = policyIdFilter ? `.*${policyIdFilter}.*` : undefined;
const tokenObjects = await this.getTokenObjectsByIncludeFilter(includeFilter);
const items: UninstallTokenMetadata[] = tokenObjects
.slice((page - 1) * perPage, page * perPage)
.map<UninstallTokenMetadata>(({ _id, _source }) => {
this.assertPolicyId(_source[UNINSTALL_TOKENS_SAVED_OBJECT_TYPE]);
this.assertCreatedAt(_source.created_at);
return {
id: _id.replace(`${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}:`, ''),
policy_id: _source[UNINSTALL_TOKENS_SAVED_OBJECT_TYPE].policy_id,
created_at: _source.created_at,
};
});
return { items, total: tokenObjects.length, page, perPage };
}
private async getDecryptedTokensForPolicyIds(policyIds: string[]): Promise<UninstallToken[]> {
const tokenObjectHits = await this.getTokenObjectsByIncludeFilter(policyIds);
if (tokenObjectHits.length === 0) {
return [];
}
const filter: string = tokenObjectHits
.map(({ _id }) => {
return `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${_id}"`;
})
.join(' or ');
return this.getDecryptedTokens({ filter });
}
private getDecryptedTokens = async (
options: Partial<SavedObjectsCreatePointInTimeFinderOptions>
): Promise<UninstallToken[]> => {
const tokensFinder =
await this.esoClient.createPointInTimeFinderDecryptedAsInternalUser<UninstallTokenSOAttributes>(
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
perPage: SO_SEARCH_LIMIT,
...options,
}
);
let tokenObject: Array<SavedObjectsFindResult<UninstallTokenSOAttributes>> = [];
for await (const result of tokensFinder.find()) {
tokenObject = result.saved_objects;
break;
}
tokensFinder.close();
const uninstallTokens: UninstallToken[] = tokenObject.map(
({ id: _id, attributes, created_at: createdAt }) => {
this.assertPolicyId(attributes);
this.assertToken(attributes);
this.assertCreatedAt(createdAt);
return {
id: _id,
policy_id: attributes.policy_id,
token: attributes.token || attributes.token_plain,
created_at: createdAt,
};
}
);
return uninstallTokens;
};
private async getTokenObjectsByIncludeFilter(
include?: AggregationsTermsInclude
): Promise<Array<SearchHit<any>>> {
const bucketSize = 10000;
const query: SavedObjectsCreatePointInTimeFinderOptions = {
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
perPage: 0,
@ -153,12 +247,14 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
top_hits: {
size: 1,
sort: [{ created_at: { order: 'desc' } }],
_source: [`${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.policy_id`, 'created_at'],
},
},
},
},
},
};
// encrypted saved objects doesn't decrypt aggregation values so we get
// the ids first from saved objects to use with encrypted saved objects
const idFinder = this.soClient.createPointInTimeFinder<
@ -178,76 +274,22 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
break;
}
const firstItemsIndexInPage = (page - 1) * perPage;
const isCurrentPageEmpty = firstItemsIndexInPage >= aggResults.length;
if (isCurrentPageEmpty) {
return { items: [], total: aggResults.length, page, perPage };
}
const getCreatedAt = (soBucket: UninstallTokenSOAggregationBucket) =>
new Date(soBucket.latest.hits.hits[0]._source?.created_at ?? Date.now()).getTime();
// sort buckets by { created_at: 'desc' }
// this is done with `slice()` instead of ES, because
// 1) the query below doesn't support pagination, so we need to slice the IDs here,
// 2) the query above doesn't support bucket sorting based on sub aggregation, see this:
// https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#_ordering_by_a_sub_aggregation
// sorting and paginating buckets is done here instead of ES,
// because SO query doesn't support `bucket_sort`
aggResults.sort((a, b) => getCreatedAt(b) - getCreatedAt(a));
const filter: string = aggResults
.slice((page - 1) * perPage, page * perPage)
.map(({ latest }) => {
return `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${latest.hits.hits[0]._id}"`;
})
.join(' or ');
const tokensFinder =
await this.esoClient.createPointInTimeFinderDecryptedAsInternalUser<UninstallTokenSOAttributes>(
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
perPage: SO_SEARCH_LIMIT,
filter,
}
);
let tokenObjects: Array<SavedObjectsFindResult<UninstallTokenSOAttributes>> = [];
for await (const result of tokensFinder.find()) {
tokenObjects = result.saved_objects;
break;
}
tokensFinder.close();
const items: UninstallToken[] = tokenObjects
.filter(
({ attributes }) => attributes.policy_id && (attributes.token || attributes.token_plain)
)
.map(({ attributes, created_at: createdAt }) => ({
policy_id: attributes.policy_id,
token: attributes.token || attributes.token_plain,
...(createdAt ? { created_at: createdAt } : {}),
}));
return { items, total: aggResults.length, page, perPage };
return aggResults.map((bucket) => bucket.latest.hits.hits[0]);
}
/**
* get hashed uninstall token for given policy id
*
* @param policyId agent policy id
* @returns hashedToken
*/
public async getHashedTokenForPolicyId(policyId: string): Promise<string> {
return (await this.getHashedTokensForPolicyIds([policyId]))[policyId];
}
/**
* get hashed uninstall tokens for given policy ids
*
* @param policyIds agent policy ids
* @returns Record<policyId, hashedToken>
*/
public async getHashedTokensForPolicyIds(policyIds: string[]): Promise<Record<string, string>> {
const tokens = await this.getTokensForPolicyIds(policyIds);
const tokens = await this.getDecryptedTokensForPolicyIds(policyIds);
return tokens.reduce((acc, { policy_id: policyId, token }) => {
if (policyId && token) {
acc[policyId] = this.hashToken(token);
@ -256,36 +298,15 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
}, {} as Record<string, string>);
}
/**
* get hashed uninstall token for all policies
*
* @returns Record<policyId, hashedToken>
*/
public async getAllHashedTokens(): Promise<Record<string, string>> {
const policyIds = await this.getAllPolicyIds();
return this.getHashedTokensForPolicyIds(policyIds);
}
/**
* generate uninstall token for given policy id
* will not create a new token if one already exists for a given policy unless force: true is used
*
* @param policyId agent policy id
* @param force generate a new token even if one already exists
* @returns hashedToken
*/
public async generateTokenForPolicyId(policyId: string, force: boolean = false): Promise<string> {
return (await this.generateTokensForPolicyIds([policyId], force))[policyId];
}
/**
* generate uninstall tokens for given policy ids
* will not create a new token if one already exists for a given policy unless force: true is used
*
* @param policyIds agent policy ids
* @param force generate a new token even if one already exists
* @returns Record<policyId, hashedToken>
*/
public async generateTokensForPolicyIds(
policyIds: string[],
force: boolean = false
@ -298,7 +319,7 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
const existingTokens = force
? {}
: (await this.getTokensForPolicyIds(policyIds)).reduce(
: (await this.getDecryptedTokensForPolicyIds(policyIds)).reduce(
(acc, { policy_id: policyId, token }) => {
acc[policyId] = token;
return acc;
@ -339,13 +360,6 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
}, {} as Record<string, string>);
}
/**
* generate uninstall tokens all policies
* will not create a new token if one already exists for a given policy unless force: true is used
*
* @param force generate a new token even if one already exists
* @returns Record<policyId, hashedToken>
*/
public async generateTokensForAllPolicies(
force: boolean = false
): Promise<Record<string, string>> {
@ -353,9 +367,6 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
return this.generateTokensForPolicyIds(policyIds, force);
}
/**
* if encryption is available, checks for any plain text uninstall tokens and encrypts them
*/
public async encryptTokens(): Promise<void> {
if (!this.isEncryptionAvailable) {
return;
@ -477,4 +488,22 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
private get isEncryptionAvailable(): boolean {
return appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt ?? false;
}
private assertCreatedAt(createdAt: string | undefined): asserts createdAt is string {
if (!createdAt) {
throw new UninstallTokenError('Uninstall Token is missing creation date.');
}
}
private assertToken(attributes: UninstallTokenSOAttributes | undefined) {
if (!attributes?.token && !attributes?.token_plain) {
throw new UninstallTokenError('Uninstall Token is missing the token.');
}
}
private assertPolicyId(attributes: UninstallTokenSOAttributes | undefined) {
if (!attributes?.policy_id) {
throw new UninstallTokenError('Uninstall Token is missing policy ID.');
}
}
}

View file

@ -6,10 +6,16 @@
*/
import { schema } from '@kbn/config-schema';
export const GetUninstallTokensRequestSchema = {
export const GetUninstallTokensMetadataRequestSchema = {
query: schema.object({
policyId: schema.maybe(schema.string()),
perPage: schema.maybe(schema.number({ defaultValue: 20, min: 5 })),
page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })),
}),
};
export const GetUninstallTokenRequestSchema = {
params: schema.object({
uninstallTokenId: schema.string(),
}),
};

View file

@ -7,15 +7,13 @@
import expect from '@kbn/expect';
import {
AGENT_POLICY_API_ROUTES,
CreateAgentPolicyResponse,
UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
} from '@kbn/fleet-plugin/common';
import { UNINSTALL_TOKEN_ROUTES } from '@kbn/fleet-plugin/common/constants';
import { GetUninstallTokensResponse } from '@kbn/fleet-plugin/common/types/rest_spec/uninstall_token';
import * as uuid from 'uuid';
GetUninstallTokensMetadataResponse,
GetUninstallTokenResponse,
} from '@kbn/fleet-plugin/common/types/rest_spec/uninstall_token';
import { uninstallTokensRouteService } from '@kbn/fleet-plugin/common/services';
import { testUsers } from '../test_users';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { addUninstallTokenToPolicy, generateNPolicies } from '../../helpers';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
@ -24,202 +22,312 @@ export default function (providerContext: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
describe('Uninstall Token API', () => {
let generatedPolicyIds: Set<string>;
before(async () => {
await cleanSavedObjects();
await kibanaServer.savedObjects.cleanStandardList();
});
after(async () => {
await cleanSavedObjects();
await kibanaServer.savedObjects.cleanStandardList();
});
describe('pagination', () => {
before(async () => {
generatedPolicyIds = new Set(await generatePolicies(20));
});
describe('GET uninstall_tokens', () => {
describe('pagination', () => {
let generatedPolicyIds: Set<string>;
after(async () => {
await cleanSavedObjects();
});
before(async () => {
generatedPolicyIds = new Set(await generateNPolicies(supertest, 20));
});
it('should return tokens for all policies if number of policies is below default perPage', async () => {
const response = await supertest.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN).expect(200);
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
});
const body: GetUninstallTokensResponse = response.body;
expect(body.total).to.equal(generatedPolicyIds.size);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
expect(body.items.length).to.equal(generatedPolicyIds.size);
body.items.forEach(({ policy_id: policyId }) =>
expect(generatedPolicyIds.has(policyId)).to.be(true)
);
});
it('should return token with creation date', async () => {
const response = await supertest.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN).expect(200);
const body: GetUninstallTokensResponse = response.body;
expect(body.items[0]).to.have.property('token');
expect(body.items[0]).to.have.property('created_at');
const createdAt = new Date(body.items[0].created_at!).getTime();
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000).getTime();
expect(createdAt).to.lessThan(Date.now()).greaterThan(thirtyMinutesAgo);
});
it('should return default perPage number of tokens if total is above default perPage', async () => {
generatedPolicyIds.add((await generatePolicies(1))[0]);
const response1 = await supertest.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN).expect(200);
const body1: GetUninstallTokensResponse = response1.body;
expect(body1.total).to.equal(generatedPolicyIds.size);
expect(body1.page).to.equal(1);
expect(body1.perPage).to.equal(20);
expect(body1.items.length).to.equal(20);
const response2 = await supertest
.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN)
.query({ page: 2 })
.expect(200);
const body2: GetUninstallTokensResponse = response2.body;
expect(body2.total).to.equal(generatedPolicyIds.size);
expect(body2.page).to.equal(2);
expect(body2.perPage).to.equal(20);
expect(body2.items.length).to.equal(1);
});
it('should return all tokens via pagination', async () => {
const receivedPolicyIds: string[] = [];
for (let i = 1; i <= 4; i++) {
it('should return token metadata for all policies if number of policies is below default perPage', async () => {
const response = await supertest
.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN)
.get(uninstallTokensRouteService.getListPath())
.expect(200);
const body: GetUninstallTokensMetadataResponse = response.body;
expect(body.total).to.equal(generatedPolicyIds.size);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
expect(body.items.length).to.equal(generatedPolicyIds.size);
body.items.forEach(({ policy_id: policyId }) =>
expect(generatedPolicyIds.has(policyId)).to.be(true)
);
});
it('should return token metadata with creation date and id', async () => {
const response = await supertest
.get(uninstallTokensRouteService.getListPath())
.expect(200);
const body: GetUninstallTokensMetadataResponse = response.body;
expect(body.items[0]).to.have.property('policy_id');
expect(body.items[0]).to.have.property('created_at');
expect(body.items[0]).to.have.property('id');
const createdAt = new Date(body.items[0].created_at!).getTime();
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000).getTime();
expect(createdAt).to.lessThan(Date.now()).greaterThan(thirtyMinutesAgo);
});
it('should return default perPage number of token metadata if total is above default perPage', async () => {
generatedPolicyIds.add((await generateNPolicies(supertest, 1))[0]);
const response1 = await supertest
.get(uninstallTokensRouteService.getListPath())
.expect(200);
const body1: GetUninstallTokensMetadataResponse = response1.body;
expect(body1.total).to.equal(generatedPolicyIds.size);
expect(body1.page).to.equal(1);
expect(body1.perPage).to.equal(20);
expect(body1.items.length).to.equal(20);
const response2 = await supertest
.get(uninstallTokensRouteService.getListPath())
.query({ page: 2 })
.expect(200);
const body2: GetUninstallTokensMetadataResponse = response2.body;
expect(body2.total).to.equal(generatedPolicyIds.size);
expect(body2.page).to.equal(2);
expect(body2.perPage).to.equal(20);
expect(body2.items.length).to.equal(1);
});
it('should return metadata for all tokens via pagination', async () => {
const receivedPolicyIds: string[] = [];
for (let i = 1; i <= 4; i++) {
const response = await supertest
.get(uninstallTokensRouteService.getListPath())
.query({
perPage: 8,
page: i,
})
.expect(200);
const body: GetUninstallTokensMetadataResponse = response.body;
expect(body.total).to.equal(generatedPolicyIds.size);
expect(body.perPage).to.equal(8);
expect(body.page).to.equal(i);
const receivedIds = body.items.map(({ policy_id: policyId }) => policyId);
receivedPolicyIds.push(...receivedIds);
}
expect(receivedPolicyIds.length).to.equal(generatedPolicyIds.size);
receivedPolicyIds.forEach((policyId) =>
expect(generatedPolicyIds.has(policyId)).to.be(true)
);
});
it('should return token metadata correctly paginated and sorted by their creation date desc', async () => {
let prevCreatedAt = Date.now();
for (let i = 1; i <= 4; i++) {
const response = await supertest
.get(uninstallTokensRouteService.getListPath())
.query({
perPage: 6,
page: i,
})
.expect(200);
const body: GetUninstallTokensMetadataResponse = response.body;
body.items.forEach(({ created_at: createdAt }) => {
const currentCreatedAt = new Date(createdAt!).getTime();
const isCurrentOlderThanPrevious = currentCreatedAt <= prevCreatedAt;
expect(isCurrentOlderThanPrevious).to.be(true);
prevCreatedAt = currentCreatedAt;
});
}
});
});
describe('when there are multiple tokens for a policy', () => {
let generatedPolicyIdsArray: string[];
let timestampBeforeAddingNewTokens: number;
before(async () => {
generatedPolicyIdsArray = await generateNPolicies(supertest, 20);
timestampBeforeAddingNewTokens = Date.now();
const savingAdditionalTokensPromises = generatedPolicyIdsArray.map((id) =>
addUninstallTokenToPolicy(kibanaServer, id, `${id} latest token`)
);
await Promise.all(savingAdditionalTokensPromises);
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
});
it("should return only the latest token's metadata for every policy", async () => {
const response = await supertest
.get(uninstallTokensRouteService.getListPath())
.expect(200);
const body: GetUninstallTokensMetadataResponse = response.body;
expect(body.total).to.equal(generatedPolicyIdsArray.length);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
body.items.forEach((uninstallToken) => {
const createdAt = new Date(uninstallToken.created_at!).getTime();
expect(createdAt).to.be.greaterThan(timestampBeforeAddingNewTokens);
});
});
});
describe('when `policyId` query param is used', () => {
let generatedPolicyIdsArray: string[];
before(async () => {
generatedPolicyIdsArray = await generateNPolicies(supertest, 5);
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
});
it('should return token metadata for full policyID if found', async () => {
const selectedPolicyId = generatedPolicyIdsArray[3];
const response = await supertest
.get(uninstallTokensRouteService.getListPath())
.query({
perPage: 8,
page: i,
policyId: selectedPolicyId,
})
.expect(200);
const body: GetUninstallTokensResponse = response.body;
expect(body.total).to.equal(generatedPolicyIds.size);
expect(body.perPage).to.equal(8);
expect(body.page).to.equal(i);
const body: GetUninstallTokensMetadataResponse = response.body;
expect(body.total).to.equal(1);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
expect(body.items[0].policy_id).to.equal(selectedPolicyId);
});
const receivedIds = body.items.map(({ policy_id: policyId }) => policyId);
receivedPolicyIds.push(...receivedIds);
}
it('should return token metadata for partial policyID if found', async () => {
const selectedPolicyId = generatedPolicyIdsArray[2];
expect(receivedPolicyIds.length).to.equal(generatedPolicyIds.size);
receivedPolicyIds.forEach((policyId) =>
expect(generatedPolicyIds.has(policyId)).to.be(true)
);
const response = await supertest
.get(uninstallTokensRouteService.getListPath())
.query({
policyId: selectedPolicyId.slice(4, 11),
})
.expect(200);
const body: GetUninstallTokensMetadataResponse = response.body;
expect(body.total).to.equal(1);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
expect(body.items[0].policy_id).to.equal(selectedPolicyId);
});
it('should return nothing if policy is not found', async () => {
const response = await supertest
.get(uninstallTokensRouteService.getListPath())
.query({
policyId: 'not-existing-policy-id',
})
.expect(200);
const body: GetUninstallTokensMetadataResponse = response.body;
expect(body.total).to.equal(0);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
expect(body.items).to.eql([]);
});
});
describe('authorization', () => {
it('should return 200 if the user has FLEET ALL (and INTEGRATIONS READ) privilege', async () => {
const { username, password } = testUsers.fleet_all_int_read;
await supertestWithoutAuth
.get(uninstallTokensRouteService.getListPath())
.auth(username, password)
.expect(200);
});
it('should return 403 if the user does not have FLEET ALL privilege', async () => {
const { username, password } = testUsers.fleet_no_access;
await supertestWithoutAuth
.get(uninstallTokensRouteService.getListPath())
.auth(username, password)
.expect(403);
});
});
});
describe('when `policyId` query param is used', () => {
describe('GET uninstall_tokens/{uninstallTokenId}', () => {
let generatedUninstallTokenId: string;
before(async () => {
generatedPolicyIds = new Set(await generatePolicies(5));
generatedUninstallTokenId = await addUninstallTokenToPolicy(
kibanaServer,
'the policy id',
'the token'
);
});
after(async () => {
await cleanSavedObjects();
await kibanaServer.savedObjects.cleanStandardList();
});
it('should return token for full policyID if found', async () => {
const selectedPolicyId = [...generatedPolicyIds][3];
it('should return decrypted token', async () => {
const response = await supertest
.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN)
.query({
policyId: selectedPolicyId,
})
.get(uninstallTokensRouteService.getInfoPath(generatedUninstallTokenId))
.expect(200);
const body: GetUninstallTokensResponse = response.body;
expect(body.total).to.equal(1);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
expect(body.items[0].policy_id).to.equal(selectedPolicyId);
const body: GetUninstallTokenResponse = response.body;
expect(body.item.id).to.equal(generatedUninstallTokenId);
expect(body.item.policy_id).to.equal('the policy id');
expect(body.item.token).to.equal('the token');
expect(body.item).to.have.property('created_at');
});
it('should return token for partial policyID if found', async () => {
const selectedPolicyId = [...generatedPolicyIds][2];
it('should return 404 if token is not found', async () => {
const response = await supertest
.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN)
.query({
policyId: selectedPolicyId.slice(4, 11),
})
.expect(200);
.get(uninstallTokensRouteService.getInfoPath('i-dont-exist'))
.expect(404);
const body: GetUninstallTokensResponse = response.body;
expect(body.total).to.equal(1);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
expect(body.items[0].policy_id).to.equal(selectedPolicyId);
expect(response.body).to.have.property('statusCode', 404);
expect(response.body).to.have.property(
'message',
'Uninstall Token not found with id i-dont-exist'
);
});
it('should return nothing if policy is not found', async () => {
const response = await supertest
.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN)
.query({
policyId: 'not-existing-policy-id',
})
.expect(200);
describe('authorization', () => {
it('should return 200 if the user has FLEET ALL (and INTEGRATIONS READ) privilege', async () => {
const { username, password } = testUsers.fleet_all_int_read;
const body: GetUninstallTokensResponse = response.body;
expect(body.total).to.equal(0);
expect(body.page).to.equal(1);
expect(body.perPage).to.equal(20);
expect(body.items).to.eql([]);
});
});
await supertestWithoutAuth
.get(uninstallTokensRouteService.getInfoPath(generatedUninstallTokenId))
.auth(username, password)
.expect(200);
});
describe('authorization', () => {
it('should return 200 if the user has FLEET ALL (and INTEGRATIONS READ) privilege', async () => {
const { username, password } = testUsers.fleet_all_int_read;
it('should return 403 if the user does not have FLEET ALL privilege', async () => {
const { username, password } = testUsers.fleet_no_access;
await supertestWithoutAuth
.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN)
.auth(username, password)
.expect(200);
});
it('should return 403 if the user does not have FLEET ALL privilege', async () => {
const { username, password } = testUsers.fleet_no_access;
await supertestWithoutAuth
.get(UNINSTALL_TOKEN_ROUTES.LIST_PATTERN)
.auth(username, password)
.expect(403);
await supertestWithoutAuth
.get(uninstallTokensRouteService.getInfoPath(generatedUninstallTokenId))
.auth(username, password)
.expect(403);
});
});
});
});
const generatePolicies = async (number: number) => {
const promises = [];
for (let i = 0; i < number; i++) {
promises.push(
supertest
.post(AGENT_POLICY_API_ROUTES.CREATE_PATTERN)
.set('kbn-xsrf', 'xxxx')
.send({ name: `Agent Policy ${uuid.v4()}`, namespace: 'default' })
.expect(200)
);
}
const responses = await Promise.all(promises);
const policyIds = responses.map(({ body }) => (body as CreateAgentPolicyResponse).item.id);
return policyIds;
};
const cleanSavedObjects = async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.savedObjects.clean({ types: [UNINSTALL_TOKENS_SAVED_OBJECT_TYPE] });
};
}

View file

@ -5,7 +5,12 @@
* 2.0.
*/
import * as uuid from 'uuid';
import { ToolingLog } from '@kbn/tooling-log';
import { agentPolicyRouteService } from '@kbn/fleet-plugin/common/services';
import { CreateAgentPolicyResponse } from '@kbn/fleet-plugin/common';
import { KbnClient } from '@kbn/test';
import { UNINSTALL_TOKENS_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common';
import { FtrProviderContext } from '../api_integration/ftr_provider_context';
export function warnAndSkipTest(mochaContext: Mocha.Context, log: ToolingLog) {
@ -114,3 +119,39 @@ export function setPrereleaseSetting(supertest: any) {
.send({ prerelease_integrations_enabled: false });
});
}
export const generateNPolicies = async (supertest: any, number: number) => {
const promises = [];
for (let i = 0; i < number; i++) {
promises.push(
supertest
.post(agentPolicyRouteService.getCreatePath())
.set('kbn-xsrf', 'xxxx')
.send({ name: `Agent Policy ${uuid.v4()}`, namespace: 'default' })
.expect(200)
);
}
const responses = await Promise.all(promises);
const policyIds = responses.map(({ body }) => (body as CreateAgentPolicyResponse).item.id);
return policyIds;
};
export const addUninstallTokenToPolicy = async (
kibanaServer: KbnClient,
policyId: string,
token: string
) => {
const savedObject = await kibanaServer.savedObjects.create({
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: policyId,
token,
},
overwrite: false,
});
return savedObject.id;
};