diff --git a/x-pack/plugins/fleet/common/types/models/package_spec.ts b/x-pack/plugins/fleet/common/types/models/package_spec.ts index 6f83f3333790f..d372defd72c0e 100644 --- a/x-pack/plugins/fleet/common/types/models/package_spec.ts +++ b/x-pack/plugins/fleet/common/types/models/package_spec.ts @@ -31,6 +31,12 @@ export interface PackageSpecManifest { RegistryElasticsearch, 'index_template.settings' | 'index_template.mappings' | 'index_template.data_stream' >; + asset_tags?: PackageSpecTags[]; +} +export interface PackageSpecTags { + text: string; + asset_types?: string[]; + asset_ids?: string[]; } export type PackageSpecPackageType = 'integration' | 'input'; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 94f56c813dc62..8a663247819ef 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -128,6 +128,7 @@ import { import { FleetActionsClient, type FleetActionsClientInterface } from './services/actions'; import type { FilesClientFactory } from './services/files/types'; import { PolicyWatcher } from './services/agent_policy_watch'; +import { getPackageSpecTagId } from './services/epm/kibana/assets/tag_assets'; export interface FleetSetupDeps { security: SecurityPluginSetup; @@ -232,6 +233,10 @@ export interface FleetStartContract { messageSigningService: MessageSigningServiceInterface; uninstallTokenService: UninstallTokenServiceInterface; createFleetActionsClient: (packageName: string) => FleetActionsClientInterface; + /* + Function exported to allow creating unique ids for saved object tags + */ + getPackageSpecTagId: (spaceId: string, pkgName: string, tagName: string) => string; } export class FleetPlugin @@ -591,6 +596,7 @@ export class FleetPlugin createFleetActionsClient(packageName: string) { return new FleetActionsClient(core.elasticsearch.client.asInternalUser, packageName); }, + getPackageSpecTagId, }; } diff --git a/x-pack/plugins/fleet/server/services/epm/archive/parse.ts b/x-pack/plugins/fleet/server/services/epm/archive/parse.ts index 1ea081dda0218..e0111e196ddb0 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/parse.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/parse.ts @@ -26,6 +26,7 @@ import type { PackageSpecManifest, RegistryDataStreamRoutingRules, RegistryDataStreamLifecycle, + PackageSpecTags, } from '../../../../common/types'; import { RegistryInputKeys, @@ -45,6 +46,9 @@ export const DATASTREAM_MANIFEST_NAME = 'manifest.yml'; export const DATASTREAM_ROUTING_RULES_NAME = 'routing_rules.yml'; export const DATASTREAM_LIFECYCLE_NAME = 'lifecycle.yml'; +export const KIBANA_FOLDER_NAME = 'kibana'; +export const TAGS_NAME = 'tags.yml'; + const DEFAULT_RELEASE_VALUE = 'ga'; // Ingest pipelines are specified in a `data_stream//elasticsearch/ingest_pipeline/` directory where a `default` @@ -135,6 +139,7 @@ const PARSE_AND_VERIFY_ASSETS_NAME = [ MANIFEST_NAME, DATASTREAM_ROUTING_RULES_NAME, DATASTREAM_LIFECYCLE_NAME, + TAGS_NAME, ]; /** * Filter assets needed for the parse and verify archive function @@ -146,14 +151,6 @@ export function filterAssetPathForParseAndVerifyArchive(assetPath: string): bool /* This function generates a package info object (see type `ArchivePackage`) by parsing and verifying the `manifest.yml` file as well as the directory structure for the given package archive and other files adhering to the package spec: https://github.com/elastic/package-spec. - - Currently, this process is duplicative of logic that's already implemented in the Package Registry codebase, - e.g. https://github.com/elastic/package-registry/blob/main/packages/package.go. Because of this duplication, it's likely for our parsing/verification - logic to fall out of sync with the registry codebase's implementation. - - This should be addressed in https://github.com/elastic/kibana/issues/115032 - where we'll no longer use the package registry endpoint as a source of truth for package info objects, and instead Fleet will _always_ generate - them in the manner implemented below. */ export async function generatePackageInfoFromArchiveBuffer( archiveBuffer: Buffer, @@ -289,6 +286,22 @@ export function parseAndVerifyArchive( parsed.vars = parseAndVerifyVars(manifest.vars, 'manifest.yml'); } + // check that kibana/tags.yml file exists and add its content to ArchivePackage + const tagsFile = path.posix.join(toplevelDir, KIBANA_FOLDER_NAME, TAGS_NAME); + const tagsBuffer = assetsMap[tagsFile]; + + if (paths.includes(tagsFile) || tagsBuffer) { + let tags: PackageSpecTags[]; + try { + tags = yaml.safeLoad(tagsBuffer.toString()); + if (tags.length) { + parsed.asset_tags = tags; + } + } catch (error) { + throw new PackageInvalidArchiveError(`Could not parse tags file kibana/tags.yml: ${error}.`); + } + } + return parsed; } diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index e0f307156dac1..e9a4e255e9ea1 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -24,7 +24,13 @@ import type { IAssignmentService, ITagsClient } from '@kbn/saved-objects-tagging import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common'; import { getAsset, getPathParts } from '../../archive'; import { KibanaAssetType, KibanaSavedObjectType } from '../../../../types'; -import type { AssetType, AssetReference, AssetParts, Installation } from '../../../../types'; +import type { + AssetType, + AssetReference, + AssetParts, + Installation, + PackageSpecTags, +} from '../../../../types'; import { savedObjectTypes } from '../../packages'; import { indexPatternTypes, getIndexPatternSavedObjects } from '../index_pattern/install'; import { saveKibanaAssetsRefs } from '../../packages/install'; @@ -159,6 +165,7 @@ export async function installKibanaAssetsAndReferences({ paths, installedPkg, spaceId, + assetTags, }: { savedObjectsClient: SavedObjectsClientContract; savedObjectsImporter: Pick; @@ -170,6 +177,7 @@ export async function installKibanaAssetsAndReferences({ paths: string[]; installedPkg?: SavedObject; spaceId: string; + assetTags?: PackageSpecTags[]; }) { const kibanaAssets = await getKibanaAssets(paths); if (installedPkg) await deleteKibanaSavedObjectsAssets({ savedObjectsClient, installedPkg }); @@ -195,6 +203,7 @@ export async function installKibanaAssetsAndReferences({ pkgName, spaceId, importedAssets, + assetTags, }) ); diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts index 5471381686963..52102f80a7c22 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts @@ -15,6 +15,17 @@ describe('tagKibanaAssets', () => { create: jest.fn(), } as any; + const FOO_TAG_ID = 'fleet-shared-tag-test-pkg-b84ed8ed-a7b1-502f-83f6-90132e68adef-default'; + const BAR_TAG_ID = 'fleet-shared-tag-test-pkg-e8d5cf6d-de0f-5e77-9aa3-91093cdfbf62-default'; + const MY_CUSTOM_TAG_ID = 'fleet-shared-tag-test-pkg-cdc93456-cbdd-5560-a16c-117190be14ca-default'; + + const managedTagPayloadArg1 = { + color: '#0077CC', + description: '', + name: 'Managed', + }; + const managedTagPayloadArg2 = { id: 'fleet-managed-default', overwrite: true, refresh: false }; + beforeEach(() => { savedObjectTagAssignmentService.updateTagAssignments.mockReset(); savedObjectTagClient.get.mockReset(); @@ -42,7 +53,7 @@ describe('tagKibanaAssets', () => { { name: 'Managed', description: '', - color: '#FFFFFF', + color: '#0077CC', }, { id: 'fleet-managed-default', overwrite: true, refresh: false } ); @@ -50,7 +61,7 @@ describe('tagKibanaAssets', () => { { name: 'System', description: '', - color: '#FFFFFF', + color: '#4DD2CA', }, { id: 'fleet-pkg-system-default', overwrite: true, refresh: false } ); @@ -188,7 +199,7 @@ describe('tagKibanaAssets', () => { { name: 'Managed', description: '', - color: '#FFFFFF', + color: '#0077CC', }, { id: 'fleet-managed-default', overwrite: true, refresh: false } ); @@ -197,7 +208,7 @@ describe('tagKibanaAssets', () => { { name: 'System', description: '', - color: '#FFFFFF', + color: '#4DD2CA', }, { id: 'fleet-pkg-system-default', overwrite: true, refresh: false } ); @@ -235,7 +246,7 @@ describe('tagKibanaAssets', () => { { name: 'Managed', description: '', - color: '#FFFFFF', + color: '#0077CC', }, { id: 'fleet-managed-default', overwrite: true, refresh: false } ); @@ -244,7 +255,7 @@ describe('tagKibanaAssets', () => { { name: 'System', description: '', - color: '#FFFFFF', + color: '#4DD2CA', }, { id: 'system', overwrite: true, refresh: false } ); @@ -287,4 +298,506 @@ describe('tagKibanaAssets', () => { refresh: false, }); }); + + it('should create tags based on assetTags obtained from packageInfo and apply them to all taggable assets of that type', async () => { + savedObjectTagClient.get.mockRejectedValue(new Error('not found')); + savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: name.toLowerCase(), name }) + ); + const kibanaAssets = { + dashboard: [ + { id: 'dashboard1', type: 'dashboard' }, + { id: 'dashboard2', type: 'dashboard' }, + { id: 'search_id1', type: 'search' }, + { id: 'search_id2', type: 'search' }, + ], + } as any; + const assetTags = [ + { + text: 'Foo', + asset_types: ['dashboard'], + }, + ]; + await tagKibanaAssets({ + savedObjectTagAssignmentService, + savedObjectTagClient, + kibanaAssets, + pkgTitle: 'TestPackage', + pkgName: 'test-pkg', + spaceId: 'default', + importedAssets: [], + assetTags, + }); + expect(savedObjectTagClient.create).toHaveBeenCalledTimes(3); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + managedTagPayloadArg1, + managedTagPayloadArg2 + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + color: '#4DD2CA', + description: '', + name: 'TestPackage', + }, + { id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false } + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + color: expect.any(String), + description: 'Tag defined in package-spec', + name: 'Foo', + }, + { + id: FOO_TAG_ID, + overwrite: true, + refresh: false, + } + ); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(3); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'dashboard1', + type: 'dashboard', + }, + { + id: 'dashboard2', + type: 'dashboard', + }, + { + id: 'search_id1', + type: 'search', + }, + { + id: 'search_id2', + type: 'search', + }, + ], + refresh: false, + tags: ['fleet-managed-default', 'fleet-pkg-test-pkg-default'], + unassign: [], + }); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'dashboard1', + type: 'dashboard', + }, + ], + refresh: false, + tags: [FOO_TAG_ID], + unassign: [], + }); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'dashboard2', + type: 'dashboard', + }, + ], + refresh: false, + tags: [FOO_TAG_ID], + unassign: [], + }); + }); + + it('should create tags based on assetTags obtained from packageInfo and apply them to the specified taggable assets ids', async () => { + savedObjectTagClient.get.mockRejectedValue(new Error('not found')); + savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: name.toLowerCase(), name }) + ); + const kibanaAssets = { + dashboard: [ + { id: 'dashboard1', type: 'dashboard' }, + { id: 'dashboard2', type: 'dashboard' }, + { id: 'search_id1', type: 'search' }, + { id: 'search_id2', type: 'search' }, + ], + } as any; + const assetTags = [{ text: 'Bar', asset_ids: ['dashboard1', 'search_id1'] }]; + await tagKibanaAssets({ + savedObjectTagAssignmentService, + savedObjectTagClient, + kibanaAssets, + pkgTitle: 'TestPackage', + pkgName: 'test-pkg', + spaceId: 'default', + importedAssets: [], + assetTags, + }); + expect(savedObjectTagClient.create).toHaveBeenCalledTimes(3); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + managedTagPayloadArg1, + managedTagPayloadArg2 + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + color: '#4DD2CA', + description: '', + name: 'TestPackage', + }, + { id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false } + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + color: expect.any(String), + description: 'Tag defined in package-spec', + name: 'Bar', + }, + { + id: BAR_TAG_ID, + overwrite: true, + refresh: false, + } + ); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(3); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'dashboard1', + type: 'dashboard', + }, + { + id: 'dashboard2', + type: 'dashboard', + }, + { + id: 'search_id1', + type: 'search', + }, + { + id: 'search_id2', + type: 'search', + }, + ], + refresh: false, + tags: ['fleet-managed-default', 'fleet-pkg-test-pkg-default'], + unassign: [], + }); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'dashboard1', + type: 'dashboard', + }, + ], + refresh: false, + tags: [BAR_TAG_ID], + unassign: [], + }); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'search_id1', + type: 'search', + }, + ], + refresh: false, + tags: [BAR_TAG_ID], + unassign: [], + }); + }); + + it('should create tags based on assetTags obtained from packageInfo and apply them to all the specified assets', async () => { + savedObjectTagClient.get.mockRejectedValue(new Error('not found')); + savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: name.toLowerCase(), name }) + ); + const kibanaAssets = { + dashboard: [ + { id: 'dashboard1', type: 'dashboard' }, + { id: 'dashboard2', type: 'dashboard' }, + { id: 'search_id1', type: 'search' }, + ], + } as any; + const assetTags = [ + { + text: 'myCustomTag', + asset_types: ['search'], + asset_ids: ['dashboard2'], + }, + ]; + await tagKibanaAssets({ + savedObjectTagAssignmentService, + savedObjectTagClient, + kibanaAssets, + pkgTitle: 'TestPackage', + pkgName: 'test-pkg', + spaceId: 'default', + importedAssets: [], + assetTags, + }); + expect(savedObjectTagClient.create).toHaveBeenCalledTimes(3); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + managedTagPayloadArg1, + managedTagPayloadArg2 + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + color: '#4DD2CA', + description: '', + name: 'TestPackage', + }, + { id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false } + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + color: expect.any(String), + description: 'Tag defined in package-spec', + name: 'myCustomTag', + }, + { + id: MY_CUSTOM_TAG_ID, + overwrite: true, + refresh: false, + } + ); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(3); + + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'dashboard1', + type: 'dashboard', + }, + { + id: 'dashboard2', + type: 'dashboard', + }, + { + id: 'search_id1', + type: 'search', + }, + ], + refresh: false, + tags: ['fleet-managed-default', 'fleet-pkg-test-pkg-default'], + unassign: [], + }); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'search_id1', + type: 'search', + }, + ], + refresh: false, + tags: [MY_CUSTOM_TAG_ID], + unassign: [], + }); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ + assign: [ + { + id: 'dashboard2', + type: 'dashboard', + }, + ], + refresh: false, + tags: [MY_CUSTOM_TAG_ID], + unassign: [], + }); + }); + + it('should not call savedObjectTagClient.create if the tag id already exists', async () => { + savedObjectTagClient.get.mockResolvedValue({ name: 'existingTag', color: '', description: '' }); + savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: name.toLowerCase(), name }) + ); + const kibanaAssets = { + dashboard: [ + { id: 'dashboard1', type: 'dashboard' }, + { id: 'dashboard2', type: 'dashboard' }, + { id: 'search_id1', type: 'search' }, + { id: 'search_id2', type: 'search' }, + ], + } as any; + const assetTags = [ + { + text: 'Foo', + asset_types: ['dashboard'], + }, + { text: 'Bar', asset_ids: ['dashboard1', 'search_id1'] }, + { + text: 'myCustomTag', + asset_types: ['search'], + asset_ids: ['dashboard2'], + }, + ]; + await tagKibanaAssets({ + savedObjectTagAssignmentService, + savedObjectTagClient, + kibanaAssets, + pkgTitle: 'TestPackage', + pkgName: 'test-pkg', + spaceId: 'default', + importedAssets: [], + assetTags, + }); + expect(savedObjectTagClient.create).not.toHaveBeenCalled(); + }); + + it('should not call savedObjectTagClient.create if the tag id is the same but different case', async () => { + savedObjectTagClient.get.mockImplementation(async (id: string) => { + if (id === FOO_TAG_ID) { + return { + name: 'Foo', + id, + color: '', + description: '', + }; + } else throw new Error('not found'); + }); + savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: name.toLowerCase(), name }) + ); + const kibanaAssets = { + dashboard: [ + { id: 'dashboard1', type: 'dashboard' }, + { id: 'dashboard2', type: 'dashboard' }, + { id: 'search_id1', type: 'search' }, + { id: 'search_id2', type: 'search' }, + ], + } as any; + const assetTags = [ + { + text: 'foo', + asset_types: ['dashboard'], + }, + ]; + await tagKibanaAssets({ + savedObjectTagAssignmentService, + savedObjectTagClient, + kibanaAssets, + pkgTitle: 'TestPackage', + pkgName: 'test-pkg', + spaceId: 'default', + importedAssets: [], + assetTags, + }); + expect(savedObjectTagClient.create).toHaveBeenCalledTimes(2); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + managedTagPayloadArg1, + managedTagPayloadArg2 + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + name: 'TestPackage', + description: '', + color: '#4DD2CA', + }, + { id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false } + ); + }); + + it('should respect SecuritySolution tags', async () => { + savedObjectTagClient.get.mockRejectedValue(new Error('not found')); + savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: name.toLowerCase(), name }) + ); + const kibanaAssets = { + dashboard: [ + { id: 'dashboard1', type: 'dashboard' }, + { id: 'dashboard2', type: 'dashboard' }, + { id: 'search_id1', type: 'search' }, + { id: 'search_id2', type: 'search' }, + ], + } as any; + const assetTags = [ + { + text: 'SecuritySolution', + asset_types: ['dashboard'], + }, + ]; + await tagKibanaAssets({ + savedObjectTagAssignmentService, + savedObjectTagClient, + kibanaAssets, + pkgTitle: 'TestPackage', + pkgName: 'test-pkg', + spaceId: 'default', + importedAssets: [], + assetTags, + }); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + managedTagPayloadArg1, + managedTagPayloadArg2 + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + color: '#4DD2CA', + description: '', + name: 'TestPackage', + }, + { id: 'fleet-pkg-test-pkg-default', overwrite: true, refresh: false } + ); + expect(savedObjectTagClient.create).toHaveBeenCalledWith( + { + color: expect.any(String), + description: 'Tag defined in package-spec', + name: 'SecuritySolution', + }, + { id: 'SecuritySolution', overwrite: true, refresh: false } + ); + }); + + it('should only call savedObjectTagClient.create for basic tags if there are no assetTags to assign', async () => { + savedObjectTagClient.get.mockRejectedValue(new Error('not found')); + savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: name.toLowerCase(), name }) + ); + const kibanaAssets = { + dashboard: [ + { id: 'dashboard1', type: 'dashboard' }, + { id: 'dashboard2', type: 'dashboard' }, + { id: 'search_id1', type: 'search' }, + { id: 'search_id2', type: 'search' }, + ], + } as any; + + await tagKibanaAssets({ + savedObjectTagAssignmentService, + savedObjectTagClient, + kibanaAssets, + pkgTitle: 'TestPackage', + pkgName: 'test-pkg', + spaceId: 'default', + importedAssets: [], + assetTags: [], + }); + expect(savedObjectTagClient.create).toHaveBeenCalledTimes(2); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(1); + }); + + it('should only call savedObjectTagClient.create for basic tags if there are no taggable assetTags', async () => { + savedObjectTagClient.get.mockRejectedValue(new Error('not found')); + savedObjectTagClient.create.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: name.toLowerCase(), name }) + ); + const kibanaAssets = { + dashboard: [ + { id: 'dashboard1', type: 'dashboard' }, + { id: 'dashboard2', type: 'dashboard' }, + { id: 'search_id1', type: 'search' }, + { id: 'search_id2', type: 'search' }, + ], + } as any; + const assetTags = [ + { + text: 'Foo', + asset_types: ['security_rule', 'index_pattern'], + }, + ]; + + await tagKibanaAssets({ + savedObjectTagAssignmentService, + savedObjectTagClient, + kibanaAssets, + pkgTitle: 'TestPackage', + pkgName: 'test-pkg', + spaceId: 'default', + importedAssets: [], + assetTags, + }); + expect(savedObjectTagClient.create).toHaveBeenCalledTimes(3); + expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledTimes(1); + }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.ts index 14d169a406ff5..0386b8c20701f 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.ts @@ -5,24 +5,77 @@ * 2.0. */ +import { v5 as uuidv5 } from 'uuid'; +import { uniqBy } from 'lodash'; import type { SavedObjectsImportSuccess } from '@kbn/core-saved-objects-common'; import { taggableTypes } from '@kbn/saved-objects-tagging-plugin/common/constants'; import type { IAssignmentService, ITagsClient } from '@kbn/saved-objects-tagging-plugin/server'; import type { KibanaAssetType } from '../../../../../common'; +import type { PackageSpecTags } from '../../../../types'; + import { appContextService } from '../../../app_context'; import type { ArchiveAsset } from './install'; import { KibanaSavedObjectTypeMapping } from './install'; -const TAG_COLOR = '#FFFFFF'; +interface ObjectReference { + type: string; + id: string; +} +interface PackageSpecTagsAssets { + tagId: string; + assets: ObjectReference[]; +} + +interface GroupedAssets { + [assetId: string]: { type: string; tags: string[] }; +} + +const MANAGED_TAG_COLOR = '#0077CC'; +const PACKAGE_TAG_COLOR = '#4DD2CA'; const MANAGED_TAG_NAME = 'Managed'; const LEGACY_MANAGED_TAG_ID = 'managed'; +const SECURITY_SOLUTION_TAG_ID = 'SecuritySolution'; + +// the tag service only accepts 6-digits hex colors +const TAG_COLORS = [ + '#FEC514', + '#F583B7', + '#F04E98', + '#00BFB3', + '#FEC514', + '#BADA55', + '#FFA500', + '#9696F1', + '#D36086', + '#54B399', + '#AAA8A5', + '#A0A0A0', +]; const getManagedTagId = (spaceId: string) => `fleet-managed-${spaceId}`; const getPackageTagId = (spaceId: string, pkgName: string) => `fleet-pkg-${pkgName}-${spaceId}`; const getLegacyPackageTagId = (pkgName: string) => pkgName; +/* + This function is exported via fleet/plugin.ts to make it available to other plugins + The `SecuritySolution` tag is a special case that needs to be handled separately + In that case simply return `SecuritySolution` +*/ +export const getPackageSpecTagId = (spaceId: string, pkgName: string, tagName: string) => { + if (tagName.toLowerCase() === SECURITY_SOLUTION_TAG_ID.toLowerCase()) + return SECURITY_SOLUTION_TAG_ID; + // UUID v5 needs a namespace (uuid.DNS) to generate a predictable uuid + const uniqueId = uuidv5(`${tagName.toLowerCase()}`, uuidv5.DNS); + return `fleet-shared-tag-${pkgName}-${uniqueId}-${spaceId}`; +}; + +const getRandomColor = () => { + const randomizedIndex = Math.floor(Math.random() * TAG_COLORS.length); + return TAG_COLORS[randomizedIndex]; +}; + interface TagAssetsParams { savedObjectTagAssignmentService: IAssignmentService; savedObjectTagClient: ITagsClient; @@ -31,40 +84,61 @@ interface TagAssetsParams { pkgName: string; spaceId: string; importedAssets: SavedObjectsImportSuccess[]; + assetTags?: PackageSpecTags[]; } export async function tagKibanaAssets(opts: TagAssetsParams) { const { savedObjectTagAssignmentService, kibanaAssets, importedAssets } = opts; + const getNewId = (assetId: string) => importedAssets.find((imported) => imported.id === assetId)?.destinationId ?? assetId; const taggableAssets = getTaggableAssets(kibanaAssets).map((asset) => ({ ...asset, id: getNewId(asset.id), })); + if (taggableAssets.length > 0) { + const [managedTagId, packageTagId] = await Promise.all([ + ensureManagedTag(opts), + ensurePackageTag(opts), + ]); + try { + await savedObjectTagAssignmentService.updateTagAssignments({ + tags: [managedTagId, packageTagId], + assign: taggableAssets, + unassign: [], + refresh: false, + }); + } catch (error) { + if (error.status === 404) { + appContextService.getLogger().warn(error.message); + return; + } + throw error; + } - // no assets to tag - if (taggableAssets.length === 0) { - return; - } + const packageSpecAssets = await getPackageSpecTags(taggableAssets, opts); + const groupedAssets = groupByAssetId(packageSpecAssets); - const [managedTagId, packageTagId] = await Promise.all([ - ensureManagedTag(opts), - ensurePackageTag(opts), - ]); - - try { - await savedObjectTagAssignmentService.updateTagAssignments({ - tags: [managedTagId, packageTagId], - assign: taggableAssets, - unassign: [], - refresh: false, - }); - } catch (error) { - if (error.status === 404) { - appContextService.getLogger().warn(error.message); - return; + if (Object.entries(groupedAssets).length > 0) { + await Promise.all( + Object.entries(groupedAssets).map(async ([assetId, asset]) => { + try { + await savedObjectTagAssignmentService.updateTagAssignments({ + tags: asset.tags, + assign: [{ id: assetId, type: asset.type }], + unassign: [], + refresh: false, + }); + } catch (error) { + if (error.status === 404) { + appContextService.getLogger().warn(error.message); + return; + } + throw error; + } + }) + ); } - throw error; } } @@ -100,7 +174,7 @@ async function ensureManagedTag( { name: MANAGED_TAG_NAME, description: '', - color: TAG_COLOR, + color: MANAGED_TAG_COLOR, }, { id: managedTagId, overwrite: true, refresh: false } ); @@ -127,10 +201,92 @@ async function ensurePackageTag( { name: pkgTitle, description: '', - color: TAG_COLOR, + color: PACKAGE_TAG_COLOR, }, { id: packageTagId, overwrite: true, refresh: false } ); return packageTagId; } + +// Ensure that asset tags coming from the kibana/tags.yml file are correctly parsed and created +async function getPackageSpecTags( + taggableAssets: ArchiveAsset[], + opts: Pick +): Promise { + const { spaceId, savedObjectTagClient, pkgName, assetTags } = opts; + if (!assetTags || assetTags?.length === 0) return []; + + const assetsWithTags = await Promise.all( + assetTags.map(async (tag) => { + const uniqueTagId = getPackageSpecTagId(spaceId, pkgName, tag.text); + const existingPackageSpecTag = await savedObjectTagClient.get(uniqueTagId).catch(() => {}); + + if (!existingPackageSpecTag) { + await savedObjectTagClient.create( + { + name: tag.text, + description: 'Tag defined in package-spec', + color: getRandomColor(), + }, + { id: uniqueTagId, overwrite: true, refresh: false } + ); + } + const assetTypes = getAssetTypesObjectReferences(tag?.asset_types, taggableAssets); + const assetIds = getAssetIdsObjectReferences(tag?.asset_ids, taggableAssets); + const totAssetsToAssign = assetTypes.concat(assetIds); + const assetsToAssign = totAssetsToAssign.length > 0 ? uniqBy(totAssetsToAssign, 'id') : []; + + return { tagId: uniqueTagId, assets: assetsToAssign }; + }) + ); + return assetsWithTags; +} + +// Get all the assets of types defined in tag.asset_types from taggable kibanaAssets +const getAssetTypesObjectReferences = ( + assetTypes: string[] | undefined, + taggableAssets: ArchiveAsset[] +): ObjectReference[] => { + if (!assetTypes || assetTypes.length === 0) return []; + + return taggableAssets + .filter((taggable) => assetTypes.includes(taggable.type)) + .map((assetType) => { + return { type: assetType.type, id: assetType.id }; + }); +}; + +// Get the references to ids defined in tag.asset_ids from taggable kibanaAssets +const getAssetIdsObjectReferences = ( + assetIds: string[] | undefined, + taggableAssets: ArchiveAsset[] +): ObjectReference[] => { + if (!assetIds || assetIds.length === 0) return []; + + return taggableAssets + .filter((taggable) => assetIds.includes(taggable.id)) + .map((assetType) => { + return { type: assetType.type, id: assetType.id }; + }); +}; + +// Utility function that groups the assets by asset id +// It makes easier to update the tags in batches +const groupByAssetId = (packageSpecsAssets: PackageSpecTagsAssets[]): GroupedAssets => { + if (packageSpecsAssets.length === 0) return {}; + + const groupedAssets: GroupedAssets = {}; + + packageSpecsAssets.forEach(({ tagId, assets }) => { + assets.forEach((asset) => { + const { id } = asset; + + if (!groupedAssets[id]) { + groupedAssets[id] = { type: asset.type, tags: [] }; + } + groupedAssets[id].tags.push(tagId); + }); + }); + return groupedAssets; +}; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index a5b4ad6f4e00b..337cf59bbd613 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -144,6 +144,7 @@ export async function _installPackage({ installedPkg, logger, spaceId, + assetTags: packageInfo?.asset_tags, }) ); // Necessary to avoid async promise rejection warning diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index d6b739ddb77c7..ec1bde6292770 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -91,6 +91,7 @@ export type { PackageList, InstallationInfo, ActionStatusOptions, + PackageSpecTags, } from '../../common/types'; export { ElasticsearchAssetType, KibanaAssetType, KibanaSavedObjectType } from '../../common/types'; export { dataTypes } from '../../common/constants'; diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_tag_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_tag_assets.ts index aca56f5fce936..4c1882ee5ff23 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_tag_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_tag_assets.ts @@ -5,6 +5,8 @@ * 2.0. */ import expect from '@kbn/expect'; +import fs from 'fs'; +import path from 'path'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { skipIfNoDockerRegistry } from '../../helpers'; import { setupFleetAndAgents } from '../agents/services'; @@ -65,7 +67,7 @@ export default function (providerContext: FtrProviderContext) { const deleteSpace = async (spaceId: string) => { await supertest.delete(`/api/spaces/space/${spaceId}`).set('kbn-xsrf', 'xxxx').send(); }; - describe('asset tagging', () => { + describe('Assets tagging', () => { skipIfNoDockerRegistry(providerContext); setupFleetAndAgents(providerContext); @@ -151,5 +153,49 @@ export default function (providerContext: FtrProviderContext) { expect(pkgTag).equal(undefined); }); }); + + describe('Handles presence of tags inside integration package', async () => { + const testPackage = 'assets_with_tags'; + const testPackageVersion = '0.1.0'; + // tag corresponding to `OnlySomeAssets` + const ONLY_SOME_ASSETS_TAG = `fleet-shared-tag-${testPackage}-ef823f10-b5af-5fcb-95da-2340a5257599-default`; + // tag corresponding to `MixedTypesTag` + const MIXED_TYPES_TAG = `fleet-shared-tag-${testPackage}-ef823f10-b5af-5fcb-95da-2340a5257599-default`; + + before(async () => { + if (!server.enabled) return; + + const testPkgArchiveZip = path.join( + path.dirname(__filename), + '../fixtures/direct_upload_packages/assets_with_tags-0.1.0.zip' + ); + const buf = fs.readFileSync(testPkgArchiveZip); + await supertest + .post(`/api/fleet/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(200); + }); + after(async () => { + if (!server.enabled) return; + await uninstallPackage(testPackage, testPackageVersion); + await deleteTag('managed'); + }); + + it('Should create tags based on package spec tags', async () => { + const managedTag = await getTag('fleet-managed-default'); + expect(managedTag).not.equal(undefined); + + const securitySolutionTag = await getTag('SecuritySolution'); + expect(securitySolutionTag).not.equal(undefined); + + const pkgTag1 = await getTag(ONLY_SOME_ASSETS_TAG); + expect(pkgTag1).equal(undefined); + + const pkgTag2 = await getTag(MIXED_TYPES_TAG); + expect(pkgTag2).equal(undefined); + }); + }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/assets_with_tags-0.1.0.zip b/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/assets_with_tags-0.1.0.zip new file mode 100644 index 0000000000000..75c0ec39c6907 Binary files /dev/null and b/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/assets_with_tags-0.1.0.zip differ