[Fleet] add uninstall token service (#154610)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Joey F. Poon 2023-05-10 14:26:36 -05:00 committed by GitHub
parent ba6f36757e
commit cb7c0f4a6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1207 additions and 6 deletions

View file

@ -937,14 +937,14 @@
"description": {
"type": "text"
},
"kibanaSavedObjectMeta": {
"properties": {}
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {}
}
}
},
@ -1864,6 +1864,20 @@
"dynamic": false,
"properties": {}
},
"fleet-uninstall-tokens": {
"dynamic": false,
"properties": {
"created_at": {
"type": "date"
},
"policy_id": {
"type": "keyword"
},
"token_plain": {
"type": "keyword"
}
}
},
"osquery-manager-usage-metric": {
"properties": {
"count": {

View file

@ -95,6 +95,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"fleet-message-signing-keys": "0c6da6a680807e568540b2aa263ae52331ba66db",
"fleet-preconfiguration-deletion-record": "3afad160748b430427086985a3445fd8697566d5",
"fleet-proxy": "94d0a902a0fd22578d7d3a20873b95d902e25245",
"fleet-uninstall-tokens": "404109e8624f6585e4837a2d01f6e405e0c36d9d",
"graph-workspace": "565642a208fe7413b487aea979b5b153e4e74abe",
"guided-onboarding-guide-state": "3257825ae840309cb676d64b347107db7b76f30a",
"guided-onboarding-plugin-state": "2d3ef3069ca8e981cafe8647c0c4a4c20739db10",

View file

@ -202,6 +202,7 @@ describe('split .kibana index into multiple system indices', () => {
"fleet-message-signing-keys",
"fleet-preconfiguration-deletion-record",
"fleet-proxy",
"fleet-uninstall-tokens",
"graph-workspace",
"guided-onboarding-guide-state",
"guided-onboarding-plugin-state",

View file

@ -62,6 +62,7 @@ const previouslyRegisteredTypes = [
'fleet-message-signing-keys',
'fleet-preconfiguration-deletion-record',
'fleet-proxy',
'fleet-uninstall-tokens',
'graph-workspace',
'guided-setup-state',
'guided-onboarding-guide-state',

View file

@ -22,6 +22,7 @@ export * from './authz';
export * from './file_storage';
export * from './message_signing_keys';
export * from './locators';
export * from './uninstall_tokens';
// TODO: This is the default `index.max_result_window` ES setting, which dictates
// the maximum amount of results allowed to be returned from a search. It's possible

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const UNINSTALL_TOKENS_SAVED_OBJECT_TYPE = 'fleet-uninstall-tokens';

View file

@ -27,6 +27,7 @@ export {
PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE,
ASSETS_SAVED_OBJECT_TYPE,
MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE,
UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
// Fleet server index
FLEET_SERVER_SERVERS_INDEX,
FLEET_SERVER_ARTIFACTS_INDEX,

View file

@ -13,7 +13,7 @@ const meta = getESAssetMetadata();
export const FLEET_INSTALL_FORMAT_VERSION = '1.0.0';
export const FLEET_AGENT_POLICIES_SCHEMA_VERSION = '1.1.0';
export const FLEET_AGENT_POLICIES_SCHEMA_VERSION = '1.1.1';
export const FLEET_FINAL_PIPELINE_ID = '.fleet_final_pipeline-1';

View file

@ -47,6 +47,7 @@ export {
ASSETS_SAVED_OBJECT_TYPE,
GLOBAL_SETTINGS_SAVED_OBJECT_TYPE,
MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE,
UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
// Defaults
DEFAULT_OUTPUT,
DEFAULT_OUTPUT_ID,

View file

@ -76,6 +76,7 @@ export const createAppContextStartContractMock = (
telemetryEventsSender: createMockTelemetryEventsSender(),
bulkActionsResolver: {} as any,
messageSigningService: createMessageSigningServiceMock(),
uninstallTokenService: createUninstallTokenServiceMock(),
};
};
@ -171,3 +172,18 @@ export function createMessageSigningServiceMock() {
rotateKeyPair: jest.fn(),
};
}
export function createUninstallTokenServiceMock() {
return {
getTokenForPolicyId: jest.fn(),
getTokensForPolicyIds: jest.fn(),
getAllTokens: jest.fn(),
getHashedTokenForPolicyId: jest.fn(),
getHashedTokensForPolicyIds: jest.fn(),
getAllHashedTokens: jest.fn(),
generateTokenForPolicyId: jest.fn(),
generateTokensForPolicyIds: jest.fn(),
generateTokensForAllPolicies: jest.fn(),
encryptTokens: jest.fn(),
};
}

View file

@ -58,7 +58,11 @@ import type { FleetConfigType } from '../common/types';
import type { FleetAuthz } from '../common';
import type { ExperimentalFeatures } from '../common/experimental_features';
import { MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE, INTEGRATIONS_PLUGIN_ID } from '../common';
import {
MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE,
INTEGRATIONS_PLUGIN_ID,
UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
} from '../common';
import { parseExperimentalConfigValue } from '../common/experimental_features';
import type { MessageSigningServiceInterface } from './services/security';
@ -116,6 +120,10 @@ import { PackagePolicyServiceImpl } from './services/package_policy';
import { registerFleetUsageLogger, startFleetUsageLogger } from './services/fleet_usage_logger';
import { CheckDeletedFilesTask } from './tasks/check_deleted_files_task';
import { getRequestStore } from './services/request_store';
import {
UninstallTokenService,
type UninstallTokenServiceInterface,
} from './services/security/uninstall_token_service';
export interface FleetSetupDeps {
security: SecurityPluginSetup;
@ -160,6 +168,7 @@ export interface FleetAppContext {
bulkActionsResolver: BulkActionsResolver;
messageSigningService: MessageSigningServiceInterface;
auditLogger?: AuditLogger;
uninstallTokenService: UninstallTokenServiceInterface;
}
export type FleetSetupContract = void;
@ -209,6 +218,7 @@ export interface FleetStartContract {
createArtifactsClient: (packageName: string) => FleetArtifactsClient;
messageSigningService: MessageSigningServiceInterface;
uninstallTokenService: UninstallTokenServiceInterface;
}
export class FleetPlugin
@ -441,6 +451,11 @@ export class FleetPlugin
includedHiddenTypes: [MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE],
})
);
const uninstallTokenService = new UninstallTokenService(
plugins.encryptedSavedObjects.getClient({
includedHiddenTypes: [UNINSTALL_TOKENS_SAVED_OBJECT_TYPE],
})
);
appContextService.start({
elasticsearch: core.elasticsearch,
@ -465,6 +480,7 @@ export class FleetPlugin
telemetryEventsSender: this.telemetryEventsSender,
bulkActionsResolver: this.bulkActionsResolver!,
messageSigningService,
uninstallTokenService,
});
licenseService.start(plugins.licensing.license$);
@ -552,6 +568,7 @@ export class FleetPlugin
return new FleetArtifactsClient(core.elasticsearch.client.asInternalUser, packageName);
},
messageSigningService,
uninstallTokenService,
};
}

View file

@ -22,6 +22,7 @@ import {
FLEET_PROXY_SAVED_OBJECT_TYPE,
MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE,
INGEST_SAVED_OBJECT_INDEX,
UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
} from '../constants';
import {
@ -392,6 +393,22 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({
properties: {},
},
},
[UNINSTALL_TOKENS_SAVED_OBJECT_TYPE]: {
name: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
hidden: true,
namespaceType: 'agnostic',
management: {
importableAndExportable: false,
},
mappings: {
dynamic: false,
properties: {
created_at: { type: 'date' },
policy_id: { type: 'keyword' },
token_plain: { type: 'keyword' },
},
},
},
});
export function registerSavedObjects(savedObjects: SavedObjectsServiceSetup) {
@ -427,4 +444,8 @@ export function registerEncryptedSavedObjects(
type: MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE,
attributesToEncrypt: new Set(['passphrase']),
});
encryptedSavedObjects.registerType({
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributesToEncrypt: new Set(['token']),
});
}

View file

@ -188,10 +188,14 @@ export async function getFullAgentPolicy(
const messageSigningService = appContextService.getMessageSigningService();
if (messageSigningService && fullAgentPolicy.agent) {
const publicKey = await messageSigningService.getPublicKey();
const tokenHash =
(await appContextService
.getUninstallTokenService()
?.getHashedTokenForPolicyId(fullAgentPolicy.id)) ?? '';
fullAgentPolicy.agent.protection = {
enabled: false,
uninstall_token_hash: '',
uninstall_token_hash: tokenHash,
signing_key: publicKey,
};

View file

@ -246,6 +246,7 @@ class AgentPolicyService {
options
);
await appContextService.getUninstallTokenService()?.generateTokenForPolicyId(newSo.id);
await this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'created', newSo.id);
return { id: newSo.id, ...newSo.attributes };

View file

@ -46,6 +46,7 @@ import type { TelemetryEventsSender } from '../telemetry/sender';
import type { MessageSigningServiceInterface } from '..';
import type { BulkActionsResolver } from './agents';
import type { UninstallTokenServiceInterface } from './security/uninstall_token_service';
class AppContextService {
private encryptedSavedObjects: EncryptedSavedObjectsClient | undefined;
@ -69,6 +70,7 @@ class AppContextService {
private savedObjectsTagging: SavedObjectTaggingStart | undefined;
private bulkActionsResolver: BulkActionsResolver | undefined;
private messageSigningService: MessageSigningServiceInterface | undefined;
private uninstallTokenService: UninstallTokenServiceInterface | undefined;
public start(appContext: FleetAppContext) {
this.data = appContext.data;
@ -89,6 +91,7 @@ class AppContextService {
this.savedObjectsTagging = appContext.savedObjectsTagging;
this.bulkActionsResolver = appContext.bulkActionsResolver;
this.messageSigningService = appContext.messageSigningService;
this.uninstallTokenService = appContext.uninstallTokenService;
if (appContext.config$) {
this.config$ = appContext.config$;
@ -254,6 +257,10 @@ class AppContextService {
public getMessageSigningService() {
return this.messageSigningService;
}
public getUninstallTokenService() {
return this.uninstallTokenService;
}
}
export const appContextService = new AppContextService();

View file

@ -270,6 +270,9 @@ jest.mock('./app_context', () => ({
},
}
),
getUninstallTokenService: () => ({
generateTokenForPolicyId: jest.fn(),
}),
},
}));

View file

@ -0,0 +1,677 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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 { UNINSTALL_TOKENS_SAVED_OBJECT_TYPE } from '../../../constants';
import { createAppContextStartContractMock, type MockedFleetAppContext } from '../../../mocks';
import { appContextService } from '../../app_context';
import { agentPolicyService } from '../../agent_policy';
import { UninstallTokenService, type UninstallTokenServiceInterface } from '.';
describe('UninstallTokenService', () => {
let soClientMock: jest.Mocked<SavedObjectsClientContract>;
let esoClientMock: jest.Mocked<EncryptedSavedObjectsClient>;
let mockContext: MockedFleetAppContext;
let mockBuckets: any[] = [];
let uninstallTokenService: UninstallTokenServiceInterface;
function getDefaultSO(encrypted: boolean = true) {
return encrypted
? {
id: 'test-so-id',
attributes: {
policy_id: 'test-policy-id',
token: 'test-token',
},
}
: {
id: 'test-so-id',
attributes: {
policy_id: 'test-policy-id',
token_plain: 'test-token-plain',
},
};
}
function getDefaultSO2(encrypted: boolean = true) {
return encrypted
? {
id: 'test-so-id-two',
attributes: {
policy_id: 'test-policy-id-two',
token: 'test-token-two',
},
}
: {
id: 'test-so-id-two',
attributes: {
policy_id: 'test-policy-id-two',
token_plain: 'test-token-plain-two',
},
};
}
function getDefaultBuckets(encrypted: boolean = true) {
const defaultSO = getDefaultSO(encrypted);
const defaultSO2 = getDefaultSO2(encrypted);
return [
{
key: 'test-policy-id',
latest: {
hits: {
hits: [
{
_id: defaultSO.id,
...defaultSO,
},
],
},
},
},
{
key: 'test-policy-id-two',
latest: {
hits: {
hits: [
{
_id: defaultSO2.id,
...defaultSO2,
},
],
},
},
},
];
}
function mockFind(encrypted: boolean = true, savedObjects?: any[]) {
soClientMock.find = jest.fn().mockResolvedValue({
saved_objects:
savedObjects ?? getDefaultBuckets(encrypted).map((bucket) => ({ id: bucket.key })),
});
}
function mockCreatePointInTimeFinder(encrypted: boolean = true, buckets?: any[]) {
mockBuckets = buckets ?? getDefaultBuckets(encrypted);
soClientMock.createPointInTimeFinder = jest.fn().mockReturnValue({
close: jest.fn(),
find: function* asyncGenerator() {
yield {
aggregations: {
by_policy_id: {
buckets: mockBuckets,
},
},
};
},
});
}
function mockCreatePointInTimeFinderAsInternalUser(savedObjects?: any[]) {
esoClientMock.createPointInTimeFinderDecryptedAsInternalUser = jest.fn().mockResolvedValue({
close: jest.fn(),
find: function* asyncGenerator() {
yield {
saved_objects: savedObjects ?? mockBuckets.map(({ latest }) => latest.hits.hits[0]),
};
},
});
}
function setupMocks(canEncrypt: boolean = true) {
mockContext = createAppContextStartContractMock();
mockContext.encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup({
canEncrypt,
});
appContextService.start(mockContext);
esoClientMock =
mockContext.encryptedSavedObjectsStart!.getClient() as jest.Mocked<EncryptedSavedObjectsClient>;
soClientMock = appContextService
.getSavedObjects()
.getScopedClient({} as unknown as KibanaRequest) as jest.Mocked<SavedObjectsClientContract>;
agentPolicyService.deployPolicies = jest.fn();
uninstallTokenService = new UninstallTokenService(esoClientMock);
mockFind(canEncrypt);
mockCreatePointInTimeFinder(canEncrypt);
mockCreatePointInTimeFinderAsInternalUser();
}
function hashToken(token?: string): string {
if (!token) return '';
return createHash('sha256').update(token).digest('base64');
}
afterEach(() => {
mockBuckets = [];
jest.resetAllMocks();
});
describe('with encryption key configured', () => {
beforeEach(() => {
setupMocks();
});
describe('get uninstall tokens', () => {
it('can correctly getTokenForPolicyId', async () => {
const so = getDefaultSO();
const token = await uninstallTokenService.getTokenForPolicyId(so.attributes.policy_id);
expect(token).toBe(so.attributes.token);
});
it('can correctly getTokensForPolicyIds', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
const tokensMap = await uninstallTokenService.getTokensForPolicyIds([
so.attributes.policy_id,
so2.attributes.policy_id,
]);
expect(tokensMap).toEqual({
[so.attributes.policy_id]: so.attributes.token,
[so2.attributes.policy_id]: so2.attributes.token,
});
});
it('can correctly getAllTokens', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
const tokensMap = await uninstallTokenService.getAllTokens();
expect(tokensMap).toEqual({
[so.attributes.policy_id]: so.attributes.token,
[so2.attributes.policy_id]: so2.attributes.token,
});
});
});
describe('get hashed uninstall tokens', () => {
it('can correctly getHashedTokenForPolicyId', async () => {
const so = getDefaultSO();
const token = await uninstallTokenService.getHashedTokenForPolicyId(
so.attributes.policy_id
);
expect(token).toBe(hashToken(so.attributes.token));
});
it('can correctly getHashedTokensForPolicyIds', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
const tokensMap = await uninstallTokenService.getHashedTokensForPolicyIds([
so.attributes.policy_id,
so2.attributes.policy_id,
]);
expect(tokensMap).toEqual({
[so.attributes.policy_id]: hashToken(so.attributes.token),
[so2.attributes.policy_id]: hashToken(so2.attributes.token),
});
});
it('can correctly getAllHashedTokens', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
const tokensMap = await uninstallTokenService.getAllHashedTokens();
expect(tokensMap).toEqual({
[so.attributes.policy_id]: hashToken(so.attributes.token),
[so2.attributes.policy_id]: hashToken(so2.attributes.token),
});
});
});
describe('token generation', () => {
describe('existing token', () => {
describe('force = false', () => {
it('does not create new token when calling generateTokenForPolicyId', async () => {
const so = getDefaultSO();
await uninstallTokenService.generateTokenForPolicyId(so.attributes.policy_id);
expect(soClientMock.bulkCreate).not.toBeCalled();
});
it('does not create new token when calling generateTokensForPolicyIds', async () => {
const so = getDefaultSO();
await uninstallTokenService.generateTokensForPolicyIds([so.attributes.policy_id]);
expect(soClientMock.bulkCreate).not.toBeCalled();
});
it('does not create new token when calling generateTokensForAllPolicies', async () => {
await uninstallTokenService.generateTokensForAllPolicies();
expect(soClientMock.bulkCreate).not.toBeCalled();
});
});
describe('force = true', () => {
it('creates a new token when calling generateTokenForPolicyId', async () => {
const so = getDefaultSO();
await uninstallTokenService.generateTokenForPolicyId(so.attributes.policy_id, true);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token: expect.any(String),
},
},
]);
expect(agentPolicyService.deployPolicies).toBeCalledWith(soClientMock, [
so.attributes.policy_id,
]);
});
it('creates a new token when calling generateTokensForPolicyIds', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
await uninstallTokenService.generateTokensForPolicyIds(
[so.attributes.policy_id, so2.attributes.policy_id],
true
);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token: expect.any(String),
},
},
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so2.attributes.policy_id,
token: expect.any(String),
},
},
]);
expect(agentPolicyService.deployPolicies).toBeCalledWith(soClientMock, [
so.attributes.policy_id,
so2.attributes.policy_id,
]);
});
it('creates a new token when calling generateTokensForAllPolicies', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
await uninstallTokenService.generateTokensForAllPolicies(true);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token: expect.any(String),
},
},
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so2.attributes.policy_id,
token: expect.any(String),
},
},
]);
expect(agentPolicyService.deployPolicies).toBeCalledWith(soClientMock, [
so.attributes.policy_id,
so2.attributes.policy_id,
]);
});
});
});
describe('no existing token', () => {
beforeEach(() => {
mockCreatePointInTimeFinder(true, []);
mockCreatePointInTimeFinderAsInternalUser([]);
});
it('creates a new token when calling generateTokenForPolicyId', async () => {
const so = getDefaultSO();
await uninstallTokenService.generateTokenForPolicyId(so.attributes.policy_id);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token: expect.any(String),
},
},
]);
});
it('creates a new token when calling generateTokensForPolicyIds', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
await uninstallTokenService.generateTokensForPolicyIds([
so.attributes.policy_id,
so2.attributes.policy_id,
]);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token: expect.any(String),
},
},
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so2.attributes.policy_id,
token: expect.any(String),
},
},
]);
});
it('creates a new token when calling generateTokensForAllPolicies', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
await uninstallTokenService.generateTokensForAllPolicies();
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token: expect.any(String),
},
},
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so2.attributes.policy_id,
token: expect.any(String),
},
},
]);
});
});
});
});
describe('with encryption key NOT configured', () => {
beforeEach(() => {
setupMocks(false);
});
describe('get uninstall tokens', () => {
it('can correctly getTokenForPolicyId', async () => {
const so = getDefaultSO(false);
const token = await uninstallTokenService.getTokenForPolicyId(so.attributes.policy_id);
expect(token).toBe(so.attributes.token_plain);
});
it('can correctly getTokensForPolicyIds', async () => {
const so = getDefaultSO(false);
const so2 = getDefaultSO2(false);
const tokensMap = await uninstallTokenService.getTokensForPolicyIds([
so.attributes.policy_id,
so2.attributes.policy_id,
]);
expect(tokensMap).toEqual({
[so.attributes.policy_id]: so.attributes.token_plain,
[so2.attributes.policy_id]: so2.attributes.token_plain,
});
});
it('can correctly getAllTokens', async () => {
const so = getDefaultSO(false);
const so2 = getDefaultSO2(false);
const tokensMap = await uninstallTokenService.getAllTokens();
expect(tokensMap).toEqual({
[so.attributes.policy_id]: so.attributes.token_plain,
[so2.attributes.policy_id]: so2.attributes.token_plain,
});
});
});
describe('get hashed uninstall tokens', () => {
it('can correctly getHashedTokenForPolicyId', async () => {
const so = getDefaultSO(false);
const token = await uninstallTokenService.getHashedTokenForPolicyId(
so.attributes.policy_id
);
expect(token).toBe(hashToken(so.attributes.token_plain));
});
it('can correctly getHashedTokensForPolicyIds', async () => {
const so = getDefaultSO(false);
const so2 = getDefaultSO2(false);
const tokensMap = await uninstallTokenService.getHashedTokensForPolicyIds([
so.attributes.policy_id,
so2.attributes.policy_id,
]);
expect(tokensMap).toEqual({
[so.attributes.policy_id]: hashToken(so.attributes.token_plain),
[so2.attributes.policy_id]: hashToken(so2.attributes.token_plain),
});
});
it('can correctly getAllHashedTokens', async () => {
const so = getDefaultSO(false);
const so2 = getDefaultSO2(false);
const tokensMap = await uninstallTokenService.getAllHashedTokens();
expect(tokensMap).toEqual({
[so.attributes.policy_id]: hashToken(so.attributes.token_plain),
[so2.attributes.policy_id]: hashToken(so2.attributes.token_plain),
});
});
});
describe('token generation', () => {
describe('existing token', () => {
describe('force = false', () => {
it('does not create new token when calling generateTokenForPolicyId', async () => {
const so = getDefaultSO();
await uninstallTokenService.generateTokenForPolicyId(so.attributes.policy_id);
expect(soClientMock.bulkCreate).not.toBeCalled();
});
it('does not create new token when calling generateTokensForPolicyIds', async () => {
const so = getDefaultSO();
await uninstallTokenService.generateTokensForPolicyIds([so.attributes.policy_id]);
expect(soClientMock.bulkCreate).not.toBeCalled();
});
it('does not create new token when calling generateTokensForAllPolicies', async () => {
await uninstallTokenService.generateTokensForAllPolicies();
expect(soClientMock.bulkCreate).not.toBeCalled();
});
});
describe('force = true', () => {
it('creates a new token when calling generateTokenForPolicyId', async () => {
const so = getDefaultSO();
await uninstallTokenService.generateTokenForPolicyId(so.attributes.policy_id, true);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token_plain: expect.any(String),
},
},
]);
expect(agentPolicyService.deployPolicies).toBeCalledWith(soClientMock, [
so.attributes.policy_id,
]);
});
it('creates a new token when calling generateTokensForPolicyIds', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
await uninstallTokenService.generateTokensForPolicyIds(
[so.attributes.policy_id, so2.attributes.policy_id],
true
);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token_plain: expect.any(String),
},
},
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so2.attributes.policy_id,
token_plain: expect.any(String),
},
},
]);
expect(agentPolicyService.deployPolicies).toBeCalledWith(soClientMock, [
so.attributes.policy_id,
so2.attributes.policy_id,
]);
});
it('creates a new token when calling generateTokensForAllPolicies', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
await uninstallTokenService.generateTokensForAllPolicies(true);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token_plain: expect.any(String),
},
},
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so2.attributes.policy_id,
token_plain: expect.any(String),
},
},
]);
expect(agentPolicyService.deployPolicies).toBeCalledWith(soClientMock, [
so.attributes.policy_id,
so2.attributes.policy_id,
]);
});
});
});
describe('no existing token', () => {
beforeEach(() => {
mockCreatePointInTimeFinder(false, []);
mockCreatePointInTimeFinderAsInternalUser([]);
});
it('creates a new token when calling generateTokenForPolicyId', async () => {
const so = getDefaultSO();
await uninstallTokenService.generateTokenForPolicyId(so.attributes.policy_id);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token_plain: expect.any(String),
},
},
]);
});
it('creates a new token when calling generateTokensForPolicyIds', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
await uninstallTokenService.generateTokensForPolicyIds([
so.attributes.policy_id,
so2.attributes.policy_id,
]);
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token_plain: expect.any(String),
},
},
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so2.attributes.policy_id,
token_plain: expect.any(String),
},
},
]);
});
it('creates a new token when calling generateTokensForAllPolicies', async () => {
const so = getDefaultSO();
const so2 = getDefaultSO2();
await uninstallTokenService.generateTokensForAllPolicies();
expect(soClientMock.bulkCreate).toBeCalledWith([
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so.attributes.policy_id,
token_plain: expect.any(String),
},
},
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: {
policy_id: so2.attributes.policy_id,
token_plain: expect.any(String),
},
},
]);
});
});
});
it('can encryptTokens', async () => {
const so = getDefaultSO(false);
const so2 = getDefaultSO2(false);
mockContext!.encryptedSavedObjectsSetup!.canEncrypt = true;
mockFind(false, [so, so2]);
await uninstallTokenService.encryptTokens();
expect(soClientMock.bulkUpdate).toBeCalledWith([
{
id: so.id,
attributes: {
policy_id: so.attributes.policy_id,
token: so.attributes.token_plain,
token_plain: '',
},
},
{
id: so2.id,
attributes: {
policy_id: so2.attributes.policy_id,
token: so2.attributes.token_plain,
token_plain: '',
},
},
]);
});
});
});

View file

@ -0,0 +1,414 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { randomBytes, createHash } from 'crypto';
import { chunk } from 'lodash';
import type {
SavedObjectsClientContract,
SavedObjectsCreatePointInTimeFinderOptions,
SavedObjectsBulkUpdateObject,
SavedObjectsFindResult,
} from '@kbn/core-saved-objects-api-server';
import type {
AggregationsMultiBucketAggregateBase,
AggregationsTopHitsAggregate,
} from '@elastic/elasticsearch/lib/api/types';
import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server';
import type { KibanaRequest } from '@kbn/core-http-server';
import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server';
import { asyncForEach } from '@kbn/std';
import { UNINSTALL_TOKENS_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../../constants';
import { appContextService } from '../../app_context';
import { agentPolicyService } from '../../agent_policy';
interface UninstallTokenSOAttributes {
policy_id: string;
token: string;
token_plain: string;
}
interface UninstallTokenSOAggregationBucket {
key: string;
latest: AggregationsTopHitsAggregate;
}
interface UninstallTokenSOAggregation {
by_policy_id: AggregationsMultiBucketAggregateBase<UninstallTokenSOAggregationBucket>;
}
export interface UninstallTokenServiceInterface {
getTokenForPolicyId(policyId: string): Promise<string>;
getTokensForPolicyIds(policyIds: string[]): Promise<Record<string, string>>;
getAllTokens(): Promise<Record<string, string>>;
getHashedTokenForPolicyId(policyId: string): Promise<string>;
getHashedTokensForPolicyIds(policyIds?: string[]): Promise<Record<string, string>>;
getAllHashedTokens(): Promise<Record<string, string>>;
generateTokenForPolicyId(policyId: string, force?: boolean): Promise<string>;
generateTokensForPolicyIds(policyIds: string[], force?: boolean): Promise<Record<string, string>>;
generateTokensForAllPolicies(force?: boolean): Promise<Record<string, string>>;
encryptTokens(): Promise<void>;
}
export class UninstallTokenService implements UninstallTokenServiceInterface {
private _soClient: SavedObjectsClientContract | undefined;
constructor(private esoClient: EncryptedSavedObjectsClient) {}
/**
* gets uninstall token for given policy id
*
* @param policyId agent policy id
* @returns token
*/
public async getTokenForPolicyId(policyId: string): Promise<string> {
return (await this.getTokensForPolicyIds([policyId]))[policyId];
}
/**
* gets uninstall tokens for given policy ids
*
* @param policyIds agent policy ids
* @returns Record<policyId, token>
*/
public async getTokensForPolicyIds(policyIds: string[]): Promise<Record<string, string>> {
let filter = policyIds
.map((policyId) => `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.attributes.policy_id: ${policyId}`)
.join(' or ');
const bucketSize = 10000;
const query: SavedObjectsCreatePointInTimeFinderOptions = {
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
perPage: 0,
filter,
aggs: {
by_policy_id: {
terms: {
field: `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.attributes.policy_id`,
size: bucketSize,
},
aggs: {
latest: {
top_hits: {
size: 1,
sort: [{ [`${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.created_at`]: { order: 'desc' } }],
},
},
},
},
},
};
// 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<
UninstallTokenSOAttributes,
UninstallTokenSOAggregation
>(query);
let aggResults: UninstallTokenSOAggregationBucket[] = [];
for await (const result of idFinder.find()) {
if (
!result?.aggregations?.by_policy_id.buckets ||
!Array.isArray(result?.aggregations?.by_policy_id.buckets)
) {
break;
}
aggResults = result.aggregations.by_policy_id.buckets;
break;
}
filter = aggResults
.reduce((acc, { latest }) => {
const id = latest?.hits?.hits?.at(0)?._id;
if (!id) return acc;
const filterStr = `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${id}"`;
return [...acc, filterStr];
}, [] as string[])
.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 = [...tokenObjects, ...result.saved_objects];
}
tokensFinder.close();
const tokensMap = tokenObjects.reduce((acc, { attributes }) => {
const policyId = attributes.policy_id;
const token = attributes.token || attributes.token_plain;
if (!policyId || !token) {
return acc;
}
return {
...acc,
[policyId]: token,
};
}, {} as Record<string, string>);
return tokensMap;
}
/**
* gets uninstall tokens for all policies
*
* @returns Record<policyId, token>
*/
public async getAllTokens(): Promise<Record<string, string>> {
const policyIds = await this.getAllPolicyIds();
return this.getTokensForPolicyIds(policyIds);
}
/**
* 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 tokensMap = await this.getTokensForPolicyIds(policyIds);
return Object.entries(tokensMap).reduce((acc, [policyId, token]) => {
if (!policyId || !token) {
return acc;
}
return { ...acc, [policyId]: this.hashToken(token) };
}, {});
}
/**
* 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
): Promise<Record<string, string>> {
if (!policyIds.length) {
return {};
}
const existingTokens = force ? {} : await this.getTokensForPolicyIds(policyIds);
const missingTokenPolicyIds = force
? policyIds
: policyIds.filter((policyId) => !existingTokens[policyId]);
const newTokensMap = missingTokenPolicyIds.reduce((acc, policyId) => {
const token = this.generateToken();
return {
...acc,
[policyId]: token,
};
}, {} as Record<string, string>);
await this.persistTokens(missingTokenPolicyIds, newTokensMap);
if (force) {
const config = appContextService.getConfig();
const batchSize = config?.setup?.agentPolicySchemaUpgradeBatchSize ?? 100;
asyncForEach(
chunk(policyIds, batchSize),
async (policyIdsBatch) =>
await agentPolicyService.deployPolicies(this.soClient, policyIdsBatch)
);
}
const tokensMap = {
...existingTokens,
...newTokensMap,
};
return Object.entries(tokensMap).reduce(
(acc, [policyId, token]) => ({
...acc,
[policyId]: this.hashToken(token),
}),
{}
);
}
/**
* 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>> {
const policyIds = await this.getAllPolicyIds();
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;
}
const { saved_objects: unencryptedTokenObjects } =
await this.soClient.find<UninstallTokenSOAttributes>({
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
filter: `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.attributes.token_plain:* AND (NOT ${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.attributes.token_plain: "")`,
});
if (!unencryptedTokenObjects.length) {
return;
}
const bulkUpdateObjects: Array<SavedObjectsBulkUpdateObject<UninstallTokenSOAttributes>> = [];
for (const unencryptedTokenObject of unencryptedTokenObjects) {
bulkUpdateObjects.push({
...unencryptedTokenObject,
attributes: {
...unencryptedTokenObject.attributes,
token: unencryptedTokenObject.attributes.token_plain,
token_plain: '',
},
});
}
await this.soClient.bulkUpdate(bulkUpdateObjects);
}
private async getPolicyIdsBatch(
batchSize: number = SO_SEARCH_LIMIT,
page: number = 1
): Promise<string[]> {
return (
await agentPolicyService.list(this.soClient, { page, perPage: batchSize, fields: ['id'] })
).items.map((policy) => policy.id);
}
private async getAllPolicyIds(): Promise<string[]> {
const batchSize = SO_SEARCH_LIMIT;
let policyIdsBatch = await this.getPolicyIdsBatch(batchSize);
let policyIds = policyIdsBatch;
let page = 2;
while (policyIdsBatch.length === batchSize) {
policyIdsBatch = await this.getPolicyIdsBatch(batchSize, page);
policyIds = [...policyIds, ...policyIdsBatch];
page++;
}
return policyIds;
}
private async persistTokens(
policyIds: string[],
tokensMap: Record<string, string>
): Promise<void> {
if (!policyIds.length) {
return;
}
const config = appContextService.getConfig();
const batchSize = config?.setup?.agentPolicySchemaUpgradeBatchSize ?? 100;
asyncForEach(chunk(policyIds, batchSize), async (policyIdsBatch) => {
await this.soClient.bulkCreate<Partial<UninstallTokenSOAttributes>>(
policyIdsBatch.map((policyId) => ({
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
attributes: this.isEncryptionAvailable
? {
policy_id: policyId,
token: tokensMap[policyId],
}
: {
policy_id: policyId,
token_plain: tokensMap[policyId],
},
}))
);
});
}
private generateToken(): string {
return randomBytes(32).toString('hex');
}
private hashToken(token: string): string {
if (!token) {
return '';
}
const hash = createHash('sha256');
hash.update(token);
return hash.digest('base64');
}
private get soClient() {
if (this._soClient) {
return this._soClient;
}
const fakeRequest = {
headers: {},
getBasePath: () => '',
path: '/',
route: { settings: {} },
url: { href: {} },
raw: { req: { url: '/' } },
} as unknown as KibanaRequest;
this._soClient = appContextService.getSavedObjects().getScopedClient(fakeRequest, {
excludedExtensions: [SECURITY_EXTENSION_ID],
includedHiddenTypes: [UNINSTALL_TOKENS_SAVED_OBJECT_TYPE],
});
return this._soClient;
}
private get isEncryptionAvailable(): boolean {
return appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt ?? false;
}
}

View file

@ -177,6 +177,19 @@ async function createSetupSideEffects(
}
await appContextService.getMessageSigningService()?.generateKeyPair();
logger.debug('Generating Agent uninstall tokens');
if (!appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) {
logger.warn(
'xpack.encryptedSavedObjects.encryptionKey is not configured, agent uninstall tokens are being stored in plain text'
);
}
await appContextService.getUninstallTokenService()?.generateTokensForAllPolicies();
if (appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) {
logger.debug('Checking for and encrypting plain text uninstall tokens');
await appContextService.getUninstallTokenService()?.encryptTokens();
}
logger.debug('Upgrade Agent policy schema version');
await upgradeAgentPolicySchemaVersion(soClient);