[Fleet] Uninstalltoken saved object namespace agnostic and space aware (#190741)

This commit is contained in:
Nicolas Chaulet 2024-08-28 08:20:51 -04:00 committed by GitHub
parent 3c2ce3c839
commit 2c50c4504c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 232 additions and 29 deletions

View file

@ -544,6 +544,7 @@
],
"fleet-space-settings": [],
"fleet-uninstall-tokens": [
"namespaces",
"policy_id",
"token_plain"
],

View file

@ -1812,6 +1812,9 @@
"fleet-uninstall-tokens": {
"dynamic": false,
"properties": {
"namespaces": {
"type": "keyword"
},
"policy_id": {
"type": "keyword"
},

View file

@ -110,7 +110,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"fleet-proxy": "6cb688f0d2dd856400c1dbc998b28704ff70363d",
"fleet-setup-lock": "0dc784792c79b5af5a6e6b5dcac06b0dbaa90bde",
"fleet-space-settings": "b278e82a33978900e53a1253884b5bdbd929c9bb",
"fleet-uninstall-tokens": "ed8aa37e3cdd69e4360709e64944bb81cae0c025",
"fleet-uninstall-tokens": "371a691206845b364bcf6d3693ca7905ffdb71a4",
"graph-workspace": "5cc6bb1455b078fd848c37324672163f09b5e376",
"guided-onboarding-guide-state": "d338972ed887ac480c09a1a7fbf582d6a3827c91",
"guided-onboarding-plugin-state": "bc109e5ef46ca594fdc179eda15f3095ca0a37a4",

View file

@ -11,6 +11,7 @@ export interface UninstallToken {
policy_name: string | null;
token: string;
created_at: string;
namespaces?: string[];
}
export type UninstallTokenMetadata = Omit<UninstallToken, 'token'>;

View file

@ -89,4 +89,6 @@ export type FetchAllAgentPoliciesOptions = Pick<
'perPage' | 'kuery' | 'sortField' | 'sortOrder'
> & { fields?: string[] };
export type FetchAllAgentPolicyIdsOptions = Pick<ListWithKuery, 'perPage' | 'kuery'>;
export type FetchAllAgentPolicyIdsOptions = Pick<ListWithKuery, 'perPage' | 'kuery'> & {
spaceId?: string;
};

View file

@ -986,7 +986,7 @@ export const getSavedObjectTypes = (
name: MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE,
indexPattern: INGEST_SAVED_OBJECT_INDEX,
hidden: true,
namespaceType: useSpaceAwareness ? 'single' : 'agnostic',
namespaceType: 'agnostic',
management: {
importableAndExportable: false,
},
@ -999,7 +999,7 @@ export const getSavedObjectTypes = (
name: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
indexPattern: INGEST_SAVED_OBJECT_INDEX,
hidden: true,
namespaceType: useSpaceAwareness ? 'single' : 'agnostic',
namespaceType: 'agnostic',
management: {
importableAndExportable: false,
},
@ -1008,6 +1008,19 @@ export const getSavedObjectTypes = (
properties: {
policy_id: { type: 'keyword' },
token_plain: { type: 'keyword' },
namespaces: { type: 'keyword' },
},
},
modelVersions: {
'1': {
changes: [
{
type: 'mappings_addition',
addedMappings: {
namespaces: { type: 'keyword' },
},
},
],
},
},
},

View file

@ -1615,7 +1615,7 @@ class AgentPolicyService {
public async fetchAllAgentPolicyIds(
soClient: SavedObjectsClientContract,
{ perPage = 1000, kuery = undefined }: FetchAllAgentPolicyIdsOptions = {}
{ perPage = 1000, kuery = undefined, spaceId = undefined }: FetchAllAgentPolicyIdsOptions = {}
): Promise<AsyncIterable<string[]>> {
const savedObjectType = await getAgentPolicySavedObjectType();
return createSoFindIterable<{}>({
@ -1627,6 +1627,7 @@ class AgentPolicyService {
sortOrder: 'asc',
fields: ['id'],
filter: kuery ? normalizeKuery(savedObjectType, kuery) : undefined,
namespaces: spaceId ? [spaceId] : undefined,
},
resultsMapper: (data) => {
return data.saved_objects.map((agentPolicySO) => {

View file

@ -9,7 +9,11 @@ import { createHash } from 'crypto';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { SavedObjectsClientContract } from '@kbn/core/server';
import {
SECURITY_EXTENSION_ID,
SPACES_EXTENSION_ID,
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';
@ -29,6 +33,7 @@ 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 { isSpaceAwarenessEnabled } from '../../spaces/helpers';
import { UninstallTokenService, type UninstallTokenServiceInterface } from '.';
@ -42,6 +47,8 @@ interface TokenSO {
created_at: string;
}
jest.mock('../../spaces/helpers');
describe('UninstallTokenService', () => {
const now = new Date().toISOString();
const aDayAgo = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
@ -194,7 +201,7 @@ describe('UninstallTokenService', () => {
);
}
function setupMocks(canEncrypt: boolean = true) {
function setupMocks(canEncrypt: boolean = true, scoppedInSpace?: string) {
mockContext = createAppContextStartContractMock();
mockContext.encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup({
canEncrypt,
@ -204,13 +211,22 @@ describe('UninstallTokenService', () => {
mockContext.encryptedSavedObjectsStart!.getClient() as jest.Mocked<EncryptedSavedObjectsClient>;
soClientMock = appContextService
.getSavedObjects()
.getScopedClient({} as unknown as KibanaRequest) as jest.Mocked<SavedObjectsClientContract>;
.getScopedClient({} as unknown as KibanaRequest, {
excludedExtensions: [SECURITY_EXTENSION_ID, SPACES_EXTENSION_ID],
}) as jest.Mocked<SavedObjectsClientContract>;
agentPolicyService.deployPolicies = jest.fn();
getAgentPoliciesByIDsMock = jest.fn().mockResolvedValue([]);
agentPolicyService.getByIDs = getAgentPoliciesByIDsMock;
uninstallTokenService = new UninstallTokenService(esoClientMock);
if (scoppedInSpace) {
soClientMock.getCurrentNamespace.mockReturnValue(scoppedInSpace);
}
uninstallTokenService = new UninstallTokenService(
esoClientMock,
scoppedInSpace ? soClientMock : undefined
);
mockFind(canEncrypt);
mockCreatePointInTimeFinder(canEncrypt);
mockCreatePointInTimeFinderAsInternalUser();
@ -240,6 +256,7 @@ describe('UninstallTokenService', () => {
beforeEach(() => {
setupMocks(canEncrypt);
jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(false);
});
describe('get uninstall tokens', () => {
@ -277,6 +294,78 @@ describe('UninstallTokenService', () => {
);
});
it('filter namespace with scopped service and space awareneness enabled', async () => {
jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(true);
setupMocks(canEncrypt, 'test');
const so = getDefaultSO(canEncrypt);
mockCreatePointInTimeFinderAsInternalUser([so]);
getAgentPoliciesByIDsMock.mockResolvedValue([
{ id: so.attributes.policy_id, name: 'cheese' },
] as Array<Partial<AgentPolicy>>);
const token = await uninstallTokenService.getToken(so.id);
const expectedItem: UninstallToken = {
id: so.id,
policy_id: so.attributes.policy_id,
policy_name: 'cheese',
token: getToken(so, canEncrypt),
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}.attributes.namespaces:test) and (${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}:${so.id}")`,
perPage: SO_SEARCH_LIMIT,
}
);
expect(getAgentPoliciesByIDsMock).toHaveBeenCalledWith(
soClientMock,
[so.attributes.policy_id],
{ ignoreMissing: true }
);
});
it('do not filter namespace with scopped service and space awareneness disabled', async () => {
jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(false);
setupMocks(canEncrypt, 'test');
const so = getDefaultSO(canEncrypt);
mockCreatePointInTimeFinderAsInternalUser([so]);
getAgentPoliciesByIDsMock.mockResolvedValue([
{ id: so.attributes.policy_id, name: 'cheese' },
] as Array<Partial<AgentPolicy>>);
const token = await uninstallTokenService.getToken(so.id);
const expectedItem: UninstallToken = {
id: so.id,
policy_id: so.attributes.policy_id,
policy_name: 'cheese',
token: getToken(so, canEncrypt),
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,
}
);
expect(getAgentPoliciesByIDsMock).toHaveBeenCalledWith(
soClientMock,
[so.attributes.policy_id],
{ ignoreMissing: true }
);
});
it('sets `policy_name` to `null` if linked policy does not exist', async () => {
const so = getDefaultSO(canEncrypt);
mockCreatePointInTimeFinderAsInternalUser([so]);
@ -341,6 +430,72 @@ describe('UninstallTokenService', () => {
expect(actualItems).toEqual(expectedItems);
});
it('filter by namespace if service is scopped and space awareness is enabled', async () => {
setupMocks(canEncrypt, 'test');
jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(true);
const so = getDefaultSO(canEncrypt);
const so2 = getDefaultSO2(canEncrypt);
getAgentPoliciesByIDsMock.mockResolvedValue([
{ id: so2.attributes.policy_id, name: 'only I have a name' },
] as Array<Partial<AgentPolicy>>);
const actualItems = (await uninstallTokenService.getTokenMetadata()).items;
const expectedItems: UninstallTokenMetadata[] = [
{
id: so.id,
policy_id: so.attributes.policy_id,
policy_name: null,
created_at: so.created_at,
},
{
id: so2.id,
policy_id: so2.attributes.policy_id,
policy_name: 'only I have a name',
created_at: so2.created_at,
},
];
expect(actualItems).toEqual(expectedItems);
expect(soClientMock.createPointInTimeFinder).toHaveBeenCalledWith(
expect.objectContaining({
filter: `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.attributes.namespaces:test`,
})
);
});
it('do not filter by namespace if service is scopped and space awareness is disabled', async () => {
setupMocks(canEncrypt, 'test');
jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(false);
const so = getDefaultSO(canEncrypt);
const so2 = getDefaultSO2(canEncrypt);
getAgentPoliciesByIDsMock.mockResolvedValue([
{ id: so2.attributes.policy_id, name: 'only I have a name' },
] as Array<Partial<AgentPolicy>>);
const actualItems = (await uninstallTokenService.getTokenMetadata()).items;
const expectedItems: UninstallTokenMetadata[] = [
{
id: so.id,
policy_id: so.attributes.policy_id,
policy_name: null,
created_at: so.created_at,
},
{
id: so2.id,
policy_id: so2.attributes.policy_id,
policy_name: 'only I have a name',
created_at: so2.created_at,
},
];
expect(actualItems).toEqual(expectedItems);
expect(soClientMock.createPointInTimeFinder).toHaveBeenCalledWith(
expect.objectContaining({
filter: undefined,
})
);
});
it('should throw error if created_at is missing', async () => {
const defaultBuckets = getDefaultBuckets(canEncrypt);
defaultBuckets[0].latest.hits.hits[0]._source.created_at = '';

View file

@ -22,17 +22,16 @@ import type {
} 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 { SECURITY_EXTENSION_ID, SPACES_EXTENSION_ID } from '@kbn/core-saved-objects-server';
import { asyncForEach, asyncMap } from '@kbn/std';
import type {
AggregationsTermsInclude,
AggregationsTermsExclude,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { isResponseError } from '@kbn/es-errors';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';
import type { AgentPolicySOAttributes } from '../../../types';
import { UninstallTokenError } from '../../../../common/errors';
@ -45,11 +44,13 @@ import type {
import { UNINSTALL_TOKENS_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../../constants';
import { appContextService } from '../../app_context';
import { agentPolicyService, getAgentPolicySavedObjectType } from '../../agent_policy';
import { isSpaceAwarenessEnabled } from '../../spaces/helpers';
interface UninstallTokenSOAttributes {
policy_id: string;
token: string;
token_plain: string;
namespaces?: string[];
}
interface UninstallTokenSOAggregationBucket {
@ -61,6 +62,14 @@ interface UninstallTokenSOAggregation {
by_policy_id: AggregationsMultiBucketAggregateBase<UninstallTokenSOAggregationBucket>;
}
function getNamespaceFiltering(namespace: string) {
if (namespace === DEFAULT_NAMESPACE_STRING) {
return `(${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.attributes.namespaces:default) or (not ${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.attributes.namespaces:*)`;
}
return `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.attributes.namespaces:${namespace}`;
}
export interface UninstallTokenInvalidError {
error: UninstallTokenError;
}
@ -189,11 +198,15 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
}
public async getToken(id: string): Promise<UninstallToken | null> {
const namespacePrefix = this.soClient.getCurrentNamespace()
? `${this.soClient.getCurrentNamespace()}:`
: '';
const filter = `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${namespacePrefix}${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}:${id}"`;
const tokenObjects = await this.getDecryptedTokenObjects({ filter });
const useSpaceAwareness = this.isScoped && (await isSpaceAwarenessEnabled());
const namespaceFilter = useSpaceAwareness
? getNamespaceFiltering(this.soClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID)
: undefined;
const filter = `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}:${id}"`;
const tokenObjects = await this.getDecryptedTokenObjects({
filter: namespaceFilter ? `(${namespaceFilter}) and (${filter})` : filter,
});
return tokenObjects.length === 1
? this.convertTokenObjectToToken(
await this.getPolicyIdNameDictionary([tokenObjects[0].attributes.policy_id]),
@ -278,14 +291,12 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
this.assertCreatedAt(_source.created_at);
const policyId = _source[UNINSTALL_TOKENS_SAVED_OBJECT_TYPE].policy_id;
const namespacePrefix = this.soClient.getCurrentNamespace()
? `${this.soClient.getCurrentNamespace()}:`
: '';
return {
id: _id!.replace(`${namespacePrefix}${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}:`, ''),
id: _id!.replace(`${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}:`, ''),
policy_id: policyId,
policy_name: policyIdNameDictionary[policyId] ?? null,
created_at: _source.created_at,
namespaces: _source.namespaces,
};
}
);
@ -375,9 +386,6 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
{
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
perPage: SO_SEARCH_LIMIT,
namespaces: this.isScoped
? [this.soClient.getCurrentNamespace() || DEFAULT_SPACE_ID]
: undefined,
...options,
}
);
@ -415,6 +423,7 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
policy_name: policyIdNameDictionary[attributes.policy_id] ?? null,
token: attributes.token || attributes.token_plain,
created_at: createdAt,
namespaces: attributes.namespaces,
};
};
@ -423,9 +432,18 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
exclude?: AggregationsTermsExclude
): Promise<Array<SearchHit<any>>> {
const bucketSize = 10000;
const useSpaceAwareness = await isSpaceAwarenessEnabled();
const filter =
this.isScoped && useSpaceAwareness
? getNamespaceFiltering(this.soClient.getCurrentNamespace() || DEFAULT_NAMESPACE_STRING)
: undefined;
const query: SavedObjectsCreatePointInTimeFinderOptions = {
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
perPage: 0,
filter,
aggs: {
by_policy_id: {
terms: {
@ -574,7 +592,9 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
}
private async getAllPolicyIds(): Promise<string[]> {
const agentPolicyIdsFetcher = await agentPolicyService.fetchAllAgentPolicyIds(this.soClient);
const agentPolicyIdsFetcher = await agentPolicyService.fetchAllAgentPolicyIds(this.soClient, {
spaceId: '*',
});
const policyIds: string[] = [];
for await (const agentPolicyId of agentPolicyIdsFetcher) {
policyIds.push(...agentPolicyId);
@ -595,20 +615,27 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
const batchSize = config?.setup?.agentPolicySchemaUpgradeBatchSize ?? 100;
await asyncForEach(chunk(policyIds, batchSize), async (policyIdsBatch) => {
const policies = await agentPolicyService.getByIDs(
appContextService.getInternalUserSOClientWithoutSpaceExtension(),
policyIds.map((id) => ({ id, spaceId: '*' }))
);
const policiesSpacesIndexedById = policies.reduce((acc, p) => {
acc[p.id] = p.space_ids;
return acc;
}, {} as { [k: string]: string[] | undefined });
await this.soClient.bulkCreate<Partial<UninstallTokenSOAttributes>>(
policyIdsBatch.map((policyId) => ({
type: UNINSTALL_TOKENS_SAVED_OBJECT_TYPE,
initialNamespaces: this.soClient.getCurrentNamespace()
? [this.soClient.getCurrentNamespace() as string]
: undefined,
attributes: this.isEncryptionAvailable
? {
policy_id: policyId,
token: tokensMap[policyId],
namespaces: policiesSpacesIndexedById[policyId],
}
: {
policy_id: policyId,
token_plain: tokensMap[policyId],
namespaces: policiesSpacesIndexedById[policyId],
},
}))
);
@ -643,7 +670,7 @@ export class UninstallTokenService implements UninstallTokenServiceInterface {
} as unknown as KibanaRequest;
this._soClient = appContextService.getSavedObjects().getScopedClient(fakeRequest, {
excludedExtensions: [SECURITY_EXTENSION_ID],
excludedExtensions: [SECURITY_EXTENSION_ID, SPACES_EXTENSION_ID],
includedHiddenTypes: [UNINSTALL_TOKENS_SAVED_OBJECT_TYPE],
});