[Fleet] Tag assets based on definitions in integration tag.yml (#162643)

Closes https://github.com/elastic/kibana/issues/152814

## Summary
Tag assets based on definitions in integrations file `tag.yml` following
these guidelines:

- Tags are not be duplicated if a fleet created tag already exists with
that text in the same space (i.e two integrations using a tag with the
same text installed in the same space should share a tag, not create
two)
- Tag ids follow the template
`fleet-shared-tag-${pkgName}-${uniqueId}-${spaceId}`
- When tag is `SecuritySolution` it generates a `SecuritySolution` tag
to maintain compatibility with security requirements
- The function that generates the unique tag ids is exported so other
plugins can use it
- The tag color is randomized from a set of known colors

### Testing
To test it, I generated a customized version of a the AWS package that
has a `kibana/tags.yml `file that follows this template, since currently
there are no existing published packages of this type.
See related [package-spec
PR](https://github.com/elastic/package-spec/pull/567)
```
- text: Foo
  asset_types:
    - dashboard
    - search
- text: Bar
  asset_ids:
    - id1
    - id2
- text: myCustomTag
  asset_types:
    - dashboard
    - map
  asset_ids:
    - id1
  ```
Note that `manifest.yml` needs to have at least:
```
  format_version: 2.10.0
  kibana.version: "^8.10.1"
```

I then verified that the tags are correctly generated and associated with correct assets:

<img width="1945" alt="Screenshot 2023-08-01 at 16 20 38" src="51ec07be-e641-47ef-9af3-75799724b7a9">


### Checklist
- [ ] [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:
Cristina Amico 2023-08-02 17:41:05 +02:00 committed by GitHub
parent 49733763ef
commit 9942fdcb2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 793 additions and 42 deletions

View file

@ -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';

View file

@ -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,
};
}

View file

@ -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/<name>/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;
}

View file

@ -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<ISavedObjectsImporter, 'import' | 'resolveImportErrors'>;
@ -170,6 +177,7 @@ export async function installKibanaAssetsAndReferences({
paths: string[];
installedPkg?: SavedObject<Installation>;
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,
})
);

View file

@ -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);
});
});

View file

@ -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),
}));
// no assets to tag
if (taggableAssets.length === 0) {
return;
}
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 (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;
}
const packageSpecAssets = await getPackageSpecTags(taggableAssets, opts);
const groupedAssets = groupByAssetId(packageSpecAssets);
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<TagAssetsParams, 'spaceId' | 'savedObjectTagClient' | 'pkgName' | 'assetTags'>
): Promise<PackageSpecTagsAssets[]> {
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;
};

View file

@ -144,6 +144,7 @@ export async function _installPackage({
installedPkg,
logger,
spaceId,
assetTags: packageInfo?.asset_tags,
})
);
// Necessary to avoid async promise rejection warning

View file

@ -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';

View file

@ -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);
});
});
});
}