mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[ML] Adds secondary authorization header to Transforms in Fleet (#154665)
## Summary The PR updates how credentials are created and managed for packages including Transforms. Previously, everything will be installed as `kibana_system` user, which has limited permissions to a specific set of indices defined internally. This PR changes so that a secondary authorization is passed to the creation of Transforms, making the permissions/privileges dependent on the logged-in user. ### Installing a package containing transforms - If the package has transforms assets to be installed, it will show warning/info call out message indicating that the transforms will be created and started with the current user's credentials/roles. <img width="1226" alt="Screen Shot 2023-04-11 at 17 45 58" src="https://user-images.githubusercontent.com/43350163/231305549-ad4c981c-e186-4431-8229-5083e9ed6fc3.png"> <img width="1226" alt="Screen Shot 2023-04-11 at 17 46 03" src="https://user-images.githubusercontent.com/43350163/231305550-7b47e95d-f876-456a-beb4-d71336a3f2cf.png"> -It will parse the authorization header (schema and credentials) from the Kibana request to the package handlers. - If the package contains transforms, and if **run_as_kibana_system: false in the any of the transform yml config** , then generate an API key from the above credential (as that Kibana user with the roles and permissions at the time of generation), and use it in `transform/_put` requests. - If user has **sufficient permissions**: - Transforms will be successfully created and started. They will be marked in the saved object reference with `deferred: false` - Transform `_meta` will have `installed_by: {username}` <img width="582" alt="Screen Shot 2023-04-11 at 14 11 43" src="https://user-images.githubusercontent.com/43350163/231305101-20a63860-6d0c-4324-ba49-bea116de1f96.png"> - Package will be successfully installed - If user has **insufficient permissions**: - Transforms will be successfully created, but fail to start. They will be marked in the saved object reference with `deferred: true` - Package will still be successfully installed. It will show warning that the package has some deferred installations. ### Deferred installations If a package has deferred installations (a.k.a assets that were included in the package, but require additional permissions to operate correctly), it will: - Show a warning on the `Installed integrations` page: <img width="1216" alt="Screen Shot 2023-04-06 at 15 59 46" src="https://user-images.githubusercontent.com/43350163/230955445-fcb575af-d02b-4b0f-96f7-7506fa4a8f02.png"> - Show a warning badge with explanation on the tab: <img width="750" alt="Screen Shot 2023-04-10 at 12 17 26" src="https://user-images.githubusercontent.com/43350163/230955055-2fa85f1f-b7f8-4473-997a-1e4fec5453b9.png"> - Show a new `Deferred installations` section as well as call out message to prompt user to re-authorize inside the `Assets` tab: <img width="1216" alt="Screen Shot 2023-04-06 at 15 59 09" src="https://user-images.githubusercontent.com/43350163/230955326-457074da-9f04-4aa6-aa15-f2c7ff14c6f1.png"> If the currently logged-in user has sufficient permissions (`manage_transform` ES cluster privilege/`transform_admin` Kibana role), the Reauthorize buttons will be enabled: <img width="1054" alt="Screen Shot 2023-04-10 at 12 24 18" src="https://user-images.githubusercontent.com/43350163/230960881-aa122119-c408-41c9-ab0c-90c18f65205e.png"> ### Reauthorizing installations - For transforms: - Clicking the `Reauthorize` button will send an `_transform/_update` API request with a `headers: {es-secondary-authorization: 'ApiKey {encoded_api}'` and then a `_transform/_start` to start operations. - Transform `_meta` will be updated with addition of `last_authorized_by: {username}` <img width="593" alt="Screen Shot 2023-04-11 at 14 12 38" src="https://user-images.githubusercontent.com/43350163/231305257-eb79cf47-dbc1-4d93-b47f-0ff698ba8e6d.png"> - If `order` is specified in `_meta` of the transform, they will be updated and started sequentially. Else, they will be executed concurrently. ## Reviewers note: -For **kibana-core**: saved object for Fleet's EsAsset was extended with `deferred: boolean`, thus changing the hash. ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [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 - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
a33b6d0731
commit
1fe26f3fba
55 changed files with 1942 additions and 295 deletions
|
@ -84,7 +84,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
|
|||
"endpoint:user-artifact": "a5b154962fb6cdf5d9e7452e58690054c95cc72a",
|
||||
"endpoint:user-artifact-manifest": "5989989c0f84dd2d02da1eb46b6254e334bd2ccd",
|
||||
"enterprise_search_telemetry": "4b41830e3b28a16eb92dee0736b44ae6276ced9b",
|
||||
"epm-packages": "83235af7c95fd9bfb1d70996a5511e05b3fcc9ef",
|
||||
"epm-packages": "8755f947a00613f994b1bc5d5580e104043e27f6",
|
||||
"epm-packages-assets": "00c8b5e5bf059627ffc9fbde920e1ac75926c5f6",
|
||||
"event_loop_delays_daily": "ef49e7f15649b551b458c7ea170f3ed17f89abd0",
|
||||
"exception-list": "38181294f64fc406c15f20d85ca306c8a4feb3c0",
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
|
||||
|
||||
import { TRANSFORM_PLUGIN_ID } from './constants/plugin';
|
||||
|
||||
import {
|
||||
calculatePackagePrivilegesFromCapabilities,
|
||||
calculatePackagePrivilegesFromKibanaPrivileges,
|
||||
|
@ -39,16 +41,33 @@ describe('fleet authz', () => {
|
|||
writeHostIsolationExceptions: true,
|
||||
writeHostIsolation: false,
|
||||
};
|
||||
|
||||
const transformCapabilities = {
|
||||
canCreateTransform: false,
|
||||
canDeleteTransform: false,
|
||||
canGetTransform: true,
|
||||
canStartStopTransform: false,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
endpoint: {
|
||||
actions: generateActions(ENDPOINT_PRIVILEGES, endpointCapabilities),
|
||||
},
|
||||
transform: {
|
||||
actions: {
|
||||
canCreateTransform: { executePackageAction: false },
|
||||
canDeleteTransform: { executePackageAction: false },
|
||||
canGetTransform: { executePackageAction: true },
|
||||
canStartStopTransform: { executePackageAction: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
const actual = calculatePackagePrivilegesFromCapabilities({
|
||||
navLinks: {},
|
||||
management: {},
|
||||
catalogue: {},
|
||||
siem: endpointCapabilities,
|
||||
transform: transformCapabilities,
|
||||
});
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
|
@ -65,6 +84,8 @@ describe('fleet authz', () => {
|
|||
{ privilege: `${SECURITY_SOLUTION_ID}-writeHostIsolationExceptions`, authorized: true },
|
||||
{ privilege: `${SECURITY_SOLUTION_ID}-writeHostIsolation`, authorized: false },
|
||||
{ privilege: `${SECURITY_SOLUTION_ID}-ignoreMe`, authorized: true },
|
||||
{ privilege: `${TRANSFORM_PLUGIN_ID}-admin`, authorized: true },
|
||||
{ privilege: `${TRANSFORM_PLUGIN_ID}-read`, authorized: true },
|
||||
];
|
||||
const expected = {
|
||||
endpoint: {
|
||||
|
@ -77,6 +98,14 @@ describe('fleet authz', () => {
|
|||
writeHostIsolation: false,
|
||||
}),
|
||||
},
|
||||
transform: {
|
||||
actions: {
|
||||
canCreateTransform: { executePackageAction: true },
|
||||
canDeleteTransform: { executePackageAction: true },
|
||||
canGetTransform: { executePackageAction: true },
|
||||
canStartStopTransform: { executePackageAction: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const actual = calculatePackagePrivilegesFromKibanaPrivileges(endpointPrivileges);
|
||||
expect(actual).toEqual(expected);
|
||||
|
|
|
@ -7,8 +7,16 @@
|
|||
|
||||
import type { Capabilities } from '@kbn/core-capabilities-common';
|
||||
|
||||
import { TRANSFORM_PLUGIN_ID } from './constants/plugin';
|
||||
|
||||
import { ENDPOINT_PRIVILEGES } from './constants';
|
||||
|
||||
export type TransformPrivilege =
|
||||
| 'canGetTransform'
|
||||
| 'canCreateTransform'
|
||||
| 'canDeleteTransform'
|
||||
| 'canStartStopTransform';
|
||||
|
||||
export interface FleetAuthz {
|
||||
fleet: {
|
||||
all: boolean;
|
||||
|
@ -106,10 +114,22 @@ export function calculatePackagePrivilegesFromCapabilities(
|
|||
{}
|
||||
);
|
||||
|
||||
const transformActions = Object.keys(capabilities.transform).reduce((acc, privilegeName) => {
|
||||
return {
|
||||
...acc,
|
||||
[privilegeName]: {
|
||||
executePackageAction: capabilities.transform[privilegeName] || false,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
|
||||
return {
|
||||
endpoint: {
|
||||
actions: endpointActions,
|
||||
},
|
||||
transform: {
|
||||
actions: transformActions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -158,9 +178,40 @@ export function calculatePackagePrivilegesFromKibanaPrivileges(
|
|||
{}
|
||||
);
|
||||
|
||||
const hasTransformAdmin = getAuthorizationFromPrivileges(
|
||||
kibanaPrivileges,
|
||||
`${TRANSFORM_PLUGIN_ID}-`,
|
||||
`admin`
|
||||
);
|
||||
const transformActions: {
|
||||
[key in TransformPrivilege]: {
|
||||
executePackageAction: boolean;
|
||||
};
|
||||
} = {
|
||||
canCreateTransform: {
|
||||
executePackageAction: hasTransformAdmin,
|
||||
},
|
||||
canDeleteTransform: {
|
||||
executePackageAction: hasTransformAdmin,
|
||||
},
|
||||
canStartStopTransform: {
|
||||
executePackageAction: hasTransformAdmin,
|
||||
},
|
||||
canGetTransform: {
|
||||
executePackageAction: getAuthorizationFromPrivileges(
|
||||
kibanaPrivileges,
|
||||
`${TRANSFORM_PLUGIN_ID}-`,
|
||||
`read`
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
endpoint: {
|
||||
actions: endpointActions,
|
||||
},
|
||||
transform: {
|
||||
actions: transformActions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -7,3 +7,4 @@
|
|||
|
||||
export const PLUGIN_ID = 'fleet' as const;
|
||||
export const INTEGRATIONS_PLUGIN_ID = 'integrations' as const;
|
||||
export const TRANSFORM_PLUGIN_ID = 'transform' as const;
|
||||
|
|
|
@ -40,6 +40,8 @@ export const EPM_API_ROUTES = {
|
|||
INFO_PATTERN_DEPRECATED: EPM_PACKAGES_ONE_DEPRECATED,
|
||||
INSTALL_FROM_REGISTRY_PATTERN_DEPRECATED: EPM_PACKAGES_ONE_DEPRECATED,
|
||||
DELETE_PATTERN_DEPRECATED: EPM_PACKAGES_ONE_DEPRECATED,
|
||||
|
||||
REAUTHORIZE_TRANSFORMS: `${EPM_PACKAGES_ONE}/transforms/authorize`,
|
||||
};
|
||||
|
||||
// Data stream API routes
|
||||
|
|
58
x-pack/plugins/fleet/common/http_authorization_header.ts
Normal file
58
x-pack/plugins/fleet/common/http_authorization_header.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 type { KibanaRequest } from '@kbn/core/server';
|
||||
|
||||
// Extended version of x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts
|
||||
// to prevent bundle being required in security_solution
|
||||
export class HTTPAuthorizationHeader {
|
||||
/**
|
||||
* The authentication scheme. Should be consumed in a case-insensitive manner.
|
||||
* https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes
|
||||
*/
|
||||
readonly scheme: string;
|
||||
|
||||
/**
|
||||
* The authentication credentials for the scheme.
|
||||
*/
|
||||
readonly credentials: string;
|
||||
|
||||
/**
|
||||
* The authentication credentials for the scheme.
|
||||
*/
|
||||
readonly username: string | undefined;
|
||||
|
||||
constructor(scheme: string, credentials: string, username?: string) {
|
||||
this.scheme = scheme;
|
||||
this.credentials = credentials;
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses request's `Authorization` HTTP header if present.
|
||||
* @param request Request instance to extract the authorization header from.
|
||||
*/
|
||||
static parseFromRequest(request: KibanaRequest, username?: string) {
|
||||
const authorizationHeaderValue = request.headers.authorization;
|
||||
if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [scheme] = authorizationHeaderValue.split(/\s+/);
|
||||
const credentials = authorizationHeaderValue.substring(scheme.length + 1);
|
||||
|
||||
return new HTTPAuthorizationHeader(scheme, credentials, username);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `${this.scheme} ${this.credentials}`;
|
||||
}
|
||||
|
||||
getUsername() {
|
||||
return this.username;
|
||||
}
|
||||
}
|
|
@ -94,6 +94,14 @@ export const createFleetAuthzMock = (): FleetAuthz => {
|
|||
endpoint: {
|
||||
actions: endpointActions,
|
||||
},
|
||||
transform: {
|
||||
actions: {
|
||||
canCreateTransform: { executePackageAction: true },
|
||||
canDeleteTransform: { executePackageAction: true },
|
||||
canGetTransform: { executePackageAction: true },
|
||||
canStartStopTransform: { executePackageAction: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -83,6 +83,12 @@ export const epmRouteService = {
|
|||
pkgVersion
|
||||
);
|
||||
},
|
||||
|
||||
getReauthorizeTransformsPath: (pkgName: string, pkgVersion: string) => {
|
||||
return EPM_API_ROUTES.REAUTHORIZE_TRANSFORMS.replace('{pkgName}', pkgName)
|
||||
.replace('{pkgVersion}', pkgVersion)
|
||||
.replace(/\/$/, ''); // trim trailing slash
|
||||
},
|
||||
};
|
||||
|
||||
export const packagePolicyRouteService = {
|
||||
|
|
|
@ -453,6 +453,7 @@ export interface IntegrationCardItem {
|
|||
id: string;
|
||||
categories: string[];
|
||||
fromIntegrations?: string;
|
||||
isReauthorizationRequired?: boolean;
|
||||
isUnverified?: boolean;
|
||||
isUpdateAvailable?: boolean;
|
||||
showLabels?: boolean;
|
||||
|
@ -539,6 +540,7 @@ export type KibanaAssetReference = Pick<SavedObjectReference, 'id'> & {
|
|||
};
|
||||
export type EsAssetReference = Pick<SavedObjectReference, 'id'> & {
|
||||
type: ElasticsearchAssetType;
|
||||
deferred?: boolean;
|
||||
};
|
||||
|
||||
export type PackageAssetReference = Pick<SavedObjectReference, 'id'> & {
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 type { GrantAPIKeyResult } from '@kbn/security-plugin/server';
|
||||
|
||||
export interface TransformAPIKey extends GrantAPIKeyResult {
|
||||
/**
|
||||
* Generated encoded API key used for headers
|
||||
*/
|
||||
encoded: string;
|
||||
}
|
||||
|
||||
export interface SecondaryAuthorizationHeader {
|
||||
headers?: { 'es-secondary-authorization': string | string[] };
|
||||
}
|
|
@ -308,6 +308,15 @@ describe('when on the package policy create page', () => {
|
|||
fireEvent.click(renderResult.getByText(/Save and continue/).closest('button')!);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(
|
||||
await renderResult.findByText(/Add Elastic Agent to your hosts/)
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
renderResult.getByText(/Add Elastic Agent to your hosts/).closest('button')!
|
||||
|
|
|
@ -22,6 +22,11 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import type { EuiStepProps } from '@elastic/eui/src/components/steps/step';
|
||||
|
||||
import {
|
||||
getNumTransformAssets,
|
||||
TransformInstallWithCurrentUserPermissionCallout,
|
||||
} from '../../../../../../components/transform_install_as_current_user_callout';
|
||||
|
||||
import { useCancelAddPackagePolicy } from '../hooks';
|
||||
|
||||
import { splitPkgKey } from '../../../../../../../common/services';
|
||||
|
@ -266,6 +271,11 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({
|
|||
]
|
||||
);
|
||||
|
||||
const numTransformAssets = useMemo(
|
||||
() => getNumTransformAssets(packageInfo?.assets),
|
||||
[packageInfo?.assets]
|
||||
);
|
||||
|
||||
const extensionView = useUIExtension(packagePolicy.package?.name ?? '', 'package-policy-create');
|
||||
const replaceDefineStepView = useUIExtension(
|
||||
packagePolicy.package?.name ?? '',
|
||||
|
@ -406,6 +416,12 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({
|
|||
integration={integrationInfo?.name}
|
||||
/>
|
||||
)}
|
||||
{numTransformAssets > 0 ? (
|
||||
<>
|
||||
<TransformInstallWithCurrentUserPermissionCallout count={numTransformAssets} />
|
||||
<EuiSpacer size="xl" />
|
||||
</>
|
||||
) : null}
|
||||
<StepsWithLessPadding steps={steps} />
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiSpacer size="xl" />
|
||||
|
|
|
@ -7,12 +7,17 @@
|
|||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { EuiBadge, EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
|
||||
import { EuiBadge, EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiToolTip } from '@elastic/eui';
|
||||
|
||||
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import {
|
||||
DEFERRED_ASSETS_WARNING_LABEL,
|
||||
DEFERRED_ASSETS_WARNING_MSG,
|
||||
} from '../screens/detail/assets/deferred_assets_warning';
|
||||
|
||||
import { CardIcon } from '../../../../../components/package_icon';
|
||||
import type { IntegrationCardItem } from '../../../../../../common/types/models/epm';
|
||||
|
||||
|
@ -39,6 +44,7 @@ export function PackageCard({
|
|||
release,
|
||||
id,
|
||||
fromIntegrations,
|
||||
isReauthorizationRequired,
|
||||
isUnverified,
|
||||
isUpdateAvailable,
|
||||
showLabels = true,
|
||||
|
@ -74,6 +80,25 @@ export function PackageCard({
|
|||
);
|
||||
}
|
||||
|
||||
let hasDeferredInstallationsBadge: React.ReactNode | null = null;
|
||||
|
||||
if (isReauthorizationRequired && showLabels) {
|
||||
hasDeferredInstallationsBadge = (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSpacer size="xs" />
|
||||
<span>
|
||||
<EuiToolTip
|
||||
display="inlineBlock"
|
||||
content={DEFERRED_ASSETS_WARNING_MSG}
|
||||
title={DEFERRED_ASSETS_WARNING_LABEL}
|
||||
>
|
||||
<EuiBadge color="warning">{DEFERRED_ASSETS_WARNING_LABEL} </EuiBadge>
|
||||
</EuiToolTip>
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
|
||||
let updateAvailableBadge: React.ReactNode | null = null;
|
||||
|
||||
if (isUpdateAvailable && showLabels) {
|
||||
|
@ -135,10 +160,11 @@ export function PackageCard({
|
|||
}
|
||||
onClick={onCardClick}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
<EuiFlexGroup gutterSize="xs" wrap={true}>
|
||||
{verifiedBadge}
|
||||
{updateAvailableBadge}
|
||||
{releaseBadge}
|
||||
{hasDeferredInstallationsBadge}
|
||||
</EuiFlexGroup>
|
||||
</Card>
|
||||
</TrackApplicationView>
|
||||
|
|
|
@ -5,18 +5,20 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer, EuiCallOut } from '@elastic/eui';
|
||||
import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import type { ResolvedSimpleSavedObject } from '@kbn/core/public';
|
||||
|
||||
import { Loading, Error, ExtensionWrapper } from '../../../../../components';
|
||||
import type { EsAssetReference } from '../../../../../../../../common';
|
||||
|
||||
import { Error, ExtensionWrapper, Loading } from '../../../../../components';
|
||||
|
||||
import type { PackageInfo } from '../../../../../types';
|
||||
import { InstallStatus } from '../../../../../types';
|
||||
import { ElasticsearchAssetType, InstallStatus } from '../../../../../types';
|
||||
|
||||
import {
|
||||
useGetPackageInstallStatus,
|
||||
|
@ -25,11 +27,14 @@ import {
|
|||
useUIExtension,
|
||||
} from '../../../../../hooks';
|
||||
|
||||
import { DeferredAssetsSection } from './deferred_assets_accordion';
|
||||
|
||||
import type { AssetSavedObject } from './types';
|
||||
import { allowedAssetTypes } from './constants';
|
||||
import { AssetsAccordion } from './assets_accordion';
|
||||
|
||||
const allowedAssetTypesLookup = new Set<string>(allowedAssetTypes);
|
||||
|
||||
interface AssetsPanelProps {
|
||||
packageInfo: PackageInfo;
|
||||
}
|
||||
|
@ -50,6 +55,8 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
|
|||
// assume assets are installed in this space until we find otherwise
|
||||
const [assetsInstalledInCurrentSpace, setAssetsInstalledInCurrentSpace] = useState<boolean>(true);
|
||||
const [assetSavedObjects, setAssetsSavedObjects] = useState<undefined | AssetSavedObject[]>();
|
||||
const [deferredInstallations, setDeferredInstallations] = useState<EsAssetReference[]>();
|
||||
|
||||
const [fetchError, setFetchError] = useState<undefined | Error>();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [hasPermissionError, setHasPermissionError] = useState<boolean>(false);
|
||||
|
@ -73,19 +80,33 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
|
|||
} = packageInfo;
|
||||
|
||||
if (
|
||||
!packageAttributes.installed_kibana ||
|
||||
packageAttributes.installed_kibana.length === 0
|
||||
Array.isArray(packageAttributes.installed_es) &&
|
||||
packageAttributes.installed_es?.length > 0
|
||||
) {
|
||||
const deferredAssets = packageAttributes.installed_es.filter(
|
||||
(asset) => asset.deferred === true
|
||||
);
|
||||
setDeferredInstallations(deferredAssets);
|
||||
}
|
||||
|
||||
const authorizedTransforms = packageAttributes.installed_es.filter(
|
||||
(asset) => asset.type === ElasticsearchAssetType.transform && !asset.deferred
|
||||
);
|
||||
|
||||
if (
|
||||
authorizedTransforms.length === 0 &&
|
||||
(!packageAttributes.installed_kibana || packageAttributes.installed_kibana.length === 0)
|
||||
) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const objectsToGet = packageAttributes.installed_kibana.map(({ id, type }) => ({
|
||||
id,
|
||||
type,
|
||||
}));
|
||||
|
||||
const objectsToGet = [...authorizedTransforms, ...packageAttributes.installed_kibana].map(
|
||||
({ id, type }) => ({
|
||||
id,
|
||||
type,
|
||||
})
|
||||
);
|
||||
// We don't have an API to know which SO types a user has access to, so instead we make a request for each
|
||||
// SO type and ignore the 403 errors
|
||||
const objectsByType = await Promise.all(
|
||||
|
@ -118,7 +139,7 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
|
|||
)
|
||||
)
|
||||
);
|
||||
setAssetsSavedObjects(objectsByType.flat());
|
||||
setAssetsSavedObjects([...objectsByType.flat()]);
|
||||
} catch (e) {
|
||||
setFetchError(e);
|
||||
} finally {
|
||||
|
@ -137,7 +158,10 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
|
|||
return <Redirect to={getPath('integration_details_overview', { pkgkey })} />;
|
||||
}
|
||||
|
||||
let content: JSX.Element | Array<JSX.Element | null>;
|
||||
const showDeferredInstallations =
|
||||
Array.isArray(deferredInstallations) && deferredInstallations.length > 0;
|
||||
|
||||
let content: JSX.Element | Array<JSX.Element | null> | null;
|
||||
if (isLoading) {
|
||||
content = <Loading />;
|
||||
} else if (fetchError) {
|
||||
|
@ -190,7 +214,7 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
|
|||
</ExtensionWrapper>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
content = !showDeferredInstallations ? (
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
|
@ -199,7 +223,7 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
|
|||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
);
|
||||
) : null;
|
||||
}
|
||||
} else {
|
||||
content = [
|
||||
|
@ -211,10 +235,14 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssetsAccordion savedObjects={sectionAssetSavedObjects} type={assetType} />
|
||||
<Fragment key={assetType}>
|
||||
<AssetsAccordion
|
||||
savedObjects={sectionAssetSavedObjects}
|
||||
type={assetType}
|
||||
key={assetType}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
}),
|
||||
// Ensure we add any custom assets provided via UI extension to the end of the list of other assets
|
||||
|
@ -225,11 +253,23 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
|
|||
) : null,
|
||||
];
|
||||
}
|
||||
const deferredInstallationsContent = showDeferredInstallations ? (
|
||||
<>
|
||||
<DeferredAssetsSection
|
||||
deferredInstallations={deferredInstallations}
|
||||
packageInfo={packageInfo}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup alignItems="flexStart">
|
||||
<EuiFlexItem grow={1} />
|
||||
<EuiFlexItem grow={6}>{content}</EuiFlexItem>
|
||||
<EuiFlexItem grow={6}>
|
||||
{deferredInstallationsContent}
|
||||
{content}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,26 +5,27 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EuiAccordion,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSplitPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiLink,
|
||||
EuiHorizontalRule,
|
||||
EuiLink,
|
||||
EuiNotificationBadge,
|
||||
EuiSpacer,
|
||||
EuiSplitPanel,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { AssetTitleMap } from '../../../constants';
|
||||
|
||||
import { getHrefToObjectInKibanaApp, useStartServices } from '../../../../../hooks';
|
||||
|
||||
import { KibanaAssetType } from '../../../../../types';
|
||||
import { ElasticsearchAssetType, KibanaAssetType } from '../../../../../types';
|
||||
|
||||
import type { AllowedAssetType, AssetSavedObject } from './types';
|
||||
|
||||
|
@ -60,8 +61,8 @@ export const AssetsAccordion: FunctionComponent<Props> = ({ savedObjects, type }
|
|||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiSplitPanel.Outer hasBorder hasShadow={false}>
|
||||
{savedObjects.map(({ id, attributes: { title, description } }, idx) => {
|
||||
// Ignore custom asset views
|
||||
{savedObjects.map(({ id, attributes: { title: soTitle, description } }, idx) => {
|
||||
// Ignore custom asset views or if not a Kibana asset
|
||||
if (type === 'view') {
|
||||
return;
|
||||
}
|
||||
|
@ -69,10 +70,11 @@ export const AssetsAccordion: FunctionComponent<Props> = ({ savedObjects, type }
|
|||
const pathToObjectInApp = getHrefToObjectInKibanaApp({
|
||||
http,
|
||||
id,
|
||||
type,
|
||||
type: type === ElasticsearchAssetType.transform ? undefined : type,
|
||||
});
|
||||
const title = soTitle ?? id;
|
||||
return (
|
||||
<>
|
||||
<Fragment key={id}>
|
||||
<EuiSplitPanel.Inner grow={false} key={idx}>
|
||||
<EuiText size="m">
|
||||
<p>
|
||||
|
@ -93,7 +95,7 @@ export const AssetsAccordion: FunctionComponent<Props> = ({ savedObjects, type }
|
|||
)}
|
||||
</EuiSplitPanel.Inner>
|
||||
{idx + 1 < savedObjects.length && <EuiHorizontalRule margin="none" />}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</EuiSplitPanel.Outer>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { KibanaAssetType } from '../../../../../types';
|
||||
import { ElasticsearchAssetType, KibanaAssetType } from '../../../../../types';
|
||||
|
||||
import type { AllowedAssetTypes } from './types';
|
||||
|
||||
|
@ -13,4 +13,5 @@ export const allowedAssetTypes: AllowedAssetTypes = [
|
|||
KibanaAssetType.dashboard,
|
||||
KibanaAssetType.search,
|
||||
KibanaAssetType.visualization,
|
||||
ElasticsearchAssetType.transform,
|
||||
];
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
import { EuiSpacer, EuiCallOut, EuiTitle } from '@elastic/eui';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import { useAuthz } from '../../../../../../../hooks';
|
||||
|
||||
import type { EsAssetReference } from '../../../../../../../../common';
|
||||
|
||||
import type { PackageInfo } from '../../../../../types';
|
||||
import { ElasticsearchAssetType } from '../../../../../types';
|
||||
|
||||
import { getDeferredInstallationMsg } from './deferred_assets_warning';
|
||||
|
||||
import { DeferredTransformAccordion } from './deferred_transforms_accordion';
|
||||
|
||||
interface Props {
|
||||
packageInfo: PackageInfo;
|
||||
deferredInstallations: EsAssetReference[];
|
||||
}
|
||||
|
||||
export const DeferredAssetsSection: FunctionComponent<Props> = ({
|
||||
deferredInstallations,
|
||||
packageInfo,
|
||||
}) => {
|
||||
const authz = useAuthz();
|
||||
|
||||
const deferredTransforms = deferredInstallations.filter(
|
||||
(asset) => asset.type === ElasticsearchAssetType.transform
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.epm.packageDetails.assets.deferredInstallationsLabel"
|
||||
defaultMessage="Deferred installations"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiCallOut
|
||||
size="m"
|
||||
color="warning"
|
||||
iconType="alert"
|
||||
title={getDeferredInstallationMsg(deferredInstallations.length, { authz })}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
|
||||
<DeferredTransformAccordion
|
||||
packageInfo={packageInfo}
|
||||
type={ElasticsearchAssetType.transform}
|
||||
deferredInstallations={deferredTransforms}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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 React, { useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiIcon, EuiToolTip } from '@elastic/eui';
|
||||
|
||||
import type { FleetAuthz } from '../../../../../../../../common';
|
||||
|
||||
import { useAuthz } from '../../../../../../../hooks';
|
||||
|
||||
export const DEFERRED_ASSETS_WARNING_LABEL = i18n.translate(
|
||||
'xpack.fleet.packageCard.reauthorizationRequiredLabel',
|
||||
{
|
||||
defaultMessage: 'Reauthorization required',
|
||||
}
|
||||
);
|
||||
|
||||
export const DEFERRED_ASSETS_WARNING_MSG = i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.deferredInstallationsMsg',
|
||||
{
|
||||
defaultMessage:
|
||||
'This package has at least one deferred installation which requires additional permissions to install and operate correctly.',
|
||||
}
|
||||
);
|
||||
|
||||
export const getDeferredInstallationMsg = (
|
||||
numOfDeferredInstallations: number | undefined | null,
|
||||
{ authz }: { authz: FleetAuthz }
|
||||
) => {
|
||||
const canReauthorizeTransforms =
|
||||
authz?.packagePrivileges?.transform?.actions?.canStartStopTransform?.executePackageAction ??
|
||||
false;
|
||||
|
||||
if (!numOfDeferredInstallations) return DEFERRED_ASSETS_WARNING_MSG;
|
||||
|
||||
if (canReauthorizeTransforms) {
|
||||
return i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.reauthorizeDeferredInstallationsMsg',
|
||||
{
|
||||
defaultMessage:
|
||||
'This package has {numOfDeferredInstallations, plural, one {one deferred installation} other {# deferred installations}}. Complete the installation to operate the package correctly.',
|
||||
values: { numOfDeferredInstallations },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return i18n.translate('xpack.fleet.epm.packageDetails.assets.deferredInstallationsWarning', {
|
||||
defaultMessage:
|
||||
'This package has {numOfDeferredInstallations, plural, one {one deferred installation which requires} other {# deferred installations which require}} additional permissions to install and operate correctly.',
|
||||
values: { numOfDeferredInstallations },
|
||||
});
|
||||
};
|
||||
|
||||
export const DeferredAssetsWarning = ({
|
||||
numOfDeferredInstallations,
|
||||
}: {
|
||||
numOfDeferredInstallations?: number;
|
||||
}) => {
|
||||
const authz = useAuthz();
|
||||
|
||||
const tooltipContent = useMemo(
|
||||
() => getDeferredInstallationMsg(numOfDeferredInstallations, { authz }),
|
||||
[numOfDeferredInstallations, authz]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiToolTip
|
||||
display="inlineBlock"
|
||||
content={tooltipContent}
|
||||
title={DEFERRED_ASSETS_WARNING_LABEL}
|
||||
>
|
||||
<EuiIcon type={'alert'} color={'warning'} />
|
||||
</EuiToolTip>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,278 @@
|
|||
/*
|
||||
* 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 React, { Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import type { FunctionComponent, MouseEvent } from 'react';
|
||||
|
||||
import {
|
||||
EuiAccordion,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSplitPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiHorizontalRule,
|
||||
EuiNotificationBadge,
|
||||
EuiButton,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import type { ElasticsearchErrorDetails } from '@kbn/es-errors';
|
||||
|
||||
import type { EsAssetReference } from '../../../../../../../../common';
|
||||
|
||||
import {
|
||||
sendRequestReauthorizeTransforms,
|
||||
useAuthz,
|
||||
useStartServices,
|
||||
} from '../../../../../../../hooks';
|
||||
|
||||
import { AssetTitleMap } from '../../../constants';
|
||||
|
||||
import type { PackageInfo } from '../../../../../types';
|
||||
import { ElasticsearchAssetType } from '../../../../../types';
|
||||
|
||||
interface Props {
|
||||
packageInfo: PackageInfo;
|
||||
type: ElasticsearchAssetType.transform;
|
||||
deferredInstallations: EsAssetReference[];
|
||||
}
|
||||
|
||||
export const getDeferredAssetDescription = (
|
||||
assetType: string,
|
||||
assetCount: number,
|
||||
permissions: { canReauthorizeTransforms: boolean }
|
||||
) => {
|
||||
switch (assetType) {
|
||||
case ElasticsearchAssetType.transform:
|
||||
if (permissions.canReauthorizeTransforms) {
|
||||
return i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.deferredTransformReauthorizeDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'{assetCount, plural, one {Transform was installed but requires} other {# transforms were installed but require}} additional permissions to run. Reauthorize the {assetCount, plural, one {transform} other {transforms}} to start operations.',
|
||||
values: { assetCount: assetCount ?? 1 },
|
||||
}
|
||||
);
|
||||
}
|
||||
return i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.deferredTransformRequestPermissionDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'{assetCount, plural, one {Transform was installed but requires} other {# transforms were installed but require}} additional permissions to run. Contact your administrator to request the required privileges.',
|
||||
values: { assetCount: assetCount ?? 1 },
|
||||
}
|
||||
);
|
||||
default:
|
||||
return i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.deferredInstallationsDescription',
|
||||
{
|
||||
defaultMessage: 'Asset requires additional permissions.',
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const DeferredTransformAccordion: FunctionComponent<Props> = ({
|
||||
packageInfo,
|
||||
type,
|
||||
deferredInstallations,
|
||||
}) => {
|
||||
const { notifications } = useStartServices();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const deferredTransforms = useMemo(
|
||||
() =>
|
||||
deferredInstallations.map((i) => ({
|
||||
id: i.id,
|
||||
attributes: {
|
||||
title: i.id,
|
||||
description: i.type,
|
||||
},
|
||||
})),
|
||||
[deferredInstallations]
|
||||
);
|
||||
|
||||
const canReauthorizeTransforms =
|
||||
useAuthz().packagePrivileges?.transform?.actions?.canStartStopTransform?.executePackageAction ??
|
||||
false;
|
||||
|
||||
const authorizeTransforms = useCallback(
|
||||
async (transformIds: Array<{ transformId: string }>) => {
|
||||
setIsLoading(true);
|
||||
notifications.toasts.addInfo(
|
||||
i18n.translate('xpack.fleet.epm.packageDetails.assets.authorizeTransformsAcknowledged', {
|
||||
defaultMessage:
|
||||
'Request to authorize {count, plural, one {# transform} other {# transforms}} acknowledged.',
|
||||
values: { count: transformIds.length },
|
||||
}),
|
||||
{ toastLifeTimeMs: 500 }
|
||||
);
|
||||
|
||||
try {
|
||||
const reauthorizeTransformResp = await sendRequestReauthorizeTransforms(
|
||||
packageInfo.name,
|
||||
packageInfo.version,
|
||||
transformIds
|
||||
);
|
||||
if (reauthorizeTransformResp.error) {
|
||||
throw reauthorizeTransformResp.error;
|
||||
}
|
||||
if (Array.isArray(reauthorizeTransformResp.data)) {
|
||||
const error = reauthorizeTransformResp.data.find((d) => d.error)?.error;
|
||||
|
||||
const cntAuthorized = reauthorizeTransformResp.data.filter((d) => d.success).length;
|
||||
if (error) {
|
||||
const errorBody = error.meta?.body as ElasticsearchErrorDetails;
|
||||
const errorMsg = errorBody
|
||||
? `${errorBody.error?.type}: ${errorBody.error?.reason}`
|
||||
: `${error.message}`;
|
||||
|
||||
notifications.toasts.addError(
|
||||
{ name: errorMsg, message: errorMsg },
|
||||
{
|
||||
title: i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.authorizeTransformsUnsuccessful',
|
||||
{
|
||||
defaultMessage:
|
||||
'Unable to authorize {cntUnauthorized, plural, one {# transform} other {# transforms}}.',
|
||||
values: { cntUnauthorized: transformIds.length - cntAuthorized },
|
||||
}
|
||||
),
|
||||
toastLifeTimeMs: 1000,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
notifications.toasts.addSuccess(
|
||||
i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.authorizeTransformsSuccessful',
|
||||
{
|
||||
defaultMessage:
|
||||
'Successfully authorized {count, plural, one {# transform} other {# transforms}}.',
|
||||
values: { count: cntAuthorized },
|
||||
}
|
||||
),
|
||||
{ toastLifeTimeMs: 1000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e) {
|
||||
notifications.toasts.addError(e, {
|
||||
title: i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.unableToAuthorizeAllTransformsError',
|
||||
{
|
||||
defaultMessage: 'An error occurred authorizing and starting transforms.',
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
[notifications.toasts, packageInfo.name, packageInfo.version]
|
||||
);
|
||||
if (deferredTransforms.length === 0) return null;
|
||||
return (
|
||||
<EuiAccordion
|
||||
initialIsOpen={true}
|
||||
buttonContent={
|
||||
<EuiFlexGroup justifyContent="center" alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="m">
|
||||
<h3>{AssetTitleMap[type]}</h3>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiNotificationBadge color="accent" size="m">
|
||||
<h3>{deferredTransforms.length}</h3>
|
||||
</EuiNotificationBadge>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
id={type}
|
||||
>
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiText>
|
||||
{getDeferredAssetDescription(type, deferredInstallations.length, {
|
||||
canReauthorizeTransforms,
|
||||
})}{' '}
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiButton
|
||||
data-test-subject={`fleetAssetsReauthorizeAll`}
|
||||
disabled={!canReauthorizeTransforms}
|
||||
isLoading={isLoading}
|
||||
size={'m'}
|
||||
onClick={(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
authorizeTransforms(deferredTransforms.map((t) => ({ transformId: t.id })));
|
||||
}}
|
||||
aria-label={getDeferredAssetDescription(type, deferredInstallations.length, {
|
||||
canReauthorizeTransforms,
|
||||
})}
|
||||
>
|
||||
{i18n.translate('xpack.fleet.epm.packageDetails.assets.reauthorizeAllButton', {
|
||||
defaultMessage: 'Reauthorize all',
|
||||
})}
|
||||
</EuiButton>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiSplitPanel.Outer hasBorder hasShadow={false}>
|
||||
{deferredTransforms.map(({ id: transformId }, idx) => {
|
||||
return (
|
||||
<Fragment key={transformId}>
|
||||
<EuiSplitPanel.Inner grow={false} key={`${transformId}-${idx}`}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={8}>
|
||||
<EuiText size="m">
|
||||
<p>{transformId}</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiToolTip
|
||||
content={
|
||||
canReauthorizeTransforms
|
||||
? undefined
|
||||
: getDeferredAssetDescription(type, 1, { canReauthorizeTransforms })
|
||||
}
|
||||
data-test-subject={`fleetAssetsReauthorizeTooltip-${transformId}-${isLoading}`}
|
||||
>
|
||||
<EuiButton
|
||||
isLoading={isLoading}
|
||||
disabled={!canReauthorizeTransforms}
|
||||
size={'s'}
|
||||
onClick={(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
authorizeTransforms([{ transformId }]);
|
||||
}}
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.fleet.epm.packageDetails.assets.reauthorizeButton',
|
||||
{
|
||||
defaultMessage: 'Reauthorize',
|
||||
}
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiSplitPanel.Inner>
|
||||
{idx + 1 < deferredTransforms.length && <EuiHorizontalRule margin="none" />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</EuiSplitPanel.Outer>
|
||||
</>
|
||||
</EuiAccordion>
|
||||
);
|
||||
};
|
|
@ -8,13 +8,15 @@
|
|||
import type { SimpleSavedObject } from '@kbn/core/public';
|
||||
|
||||
import type { KibanaAssetType } from '../../../../../types';
|
||||
import type { ElasticsearchAssetType } from '../../../../../types';
|
||||
|
||||
export type AssetSavedObject = SimpleSavedObject<{ title: string; description?: string }>;
|
||||
|
||||
export type AllowedAssetTypes = [
|
||||
KibanaAssetType.dashboard,
|
||||
KibanaAssetType.search,
|
||||
KibanaAssetType.visualization
|
||||
KibanaAssetType.visualization,
|
||||
ElasticsearchAssetType.transform
|
||||
];
|
||||
|
||||
export type AllowedAssetType = AllowedAssetTypes[number] | 'view';
|
||||
|
|
|
@ -27,6 +27,8 @@ import { i18n } from '@kbn/i18n';
|
|||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import semverLt from 'semver/functions/lt';
|
||||
|
||||
import { getDeferredInstallationsCnt } from '../../../../../../services/has_deferred_installations';
|
||||
|
||||
import {
|
||||
getPackageReleaseLabel,
|
||||
isPackagePrerelease,
|
||||
|
@ -65,6 +67,7 @@ import {
|
|||
import type { WithHeaderLayoutProps } from '../../../../layouts';
|
||||
import { WithHeaderLayout } from '../../../../layouts';
|
||||
|
||||
import { DeferredAssetsWarning } from './assets/deferred_assets_warning';
|
||||
import { useIsFirstTimeAgentUserQuery } from './hooks';
|
||||
import { getInstallPkgRouteOptions } from './utils';
|
||||
import {
|
||||
|
@ -274,6 +277,11 @@ export function Detail() {
|
|||
? getHref('integrations_installed')
|
||||
: getHref('integrations_all');
|
||||
|
||||
const numOfDeferredInstallations = useMemo(
|
||||
() => getDeferredInstallationsCnt(packageInfo),
|
||||
[packageInfo]
|
||||
);
|
||||
|
||||
const headerLeftContent = useMemo(
|
||||
() => (
|
||||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
|
@ -570,10 +578,16 @@ export function Detail() {
|
|||
tabs.push({
|
||||
id: 'assets',
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.epm.packageDetailsNav.packageAssetsLinkText"
|
||||
defaultMessage="Assets"
|
||||
/>
|
||||
<div style={{ display: 'flex', textAlign: 'center' }}>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.epm.packageDetailsNav.packageAssetsLinkText"
|
||||
defaultMessage="Assets"
|
||||
/>
|
||||
|
||||
{numOfDeferredInstallations > 0 ? (
|
||||
<DeferredAssetsWarning numOfDeferredInstallations={numOfDeferredInstallations} />
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
isSelected: panel === 'assets',
|
||||
'data-test-subj': `tab-assets`,
|
||||
|
@ -645,6 +659,7 @@ export function Detail() {
|
|||
getHref,
|
||||
integration,
|
||||
canReadIntegrationPolicies,
|
||||
numOfDeferredInstallations,
|
||||
isInstalled,
|
||||
CustomAssets,
|
||||
canReadPackageSettings,
|
||||
|
|
|
@ -26,6 +26,11 @@ import { i18n } from '@kbn/i18n';
|
|||
import type { Observable } from 'rxjs';
|
||||
import type { CoreTheme } from '@kbn/core/public';
|
||||
|
||||
import {
|
||||
getNumTransformAssets,
|
||||
TransformInstallWithCurrentUserPermissionCallout,
|
||||
} from '../../../../../../../components/transform_install_as_current_user_callout';
|
||||
|
||||
import type { PackageInfo } from '../../../../../types';
|
||||
import { InstallStatus } from '../../../../../types';
|
||||
import {
|
||||
|
@ -227,9 +232,10 @@ export const SettingsPage: React.FC<Props> = memo(({ packageInfo, theme$ }: Prop
|
|||
|
||||
const isUpdating = installationStatus === InstallStatus.installing && installedVersion;
|
||||
|
||||
const numOfAssets = useMemo(
|
||||
() =>
|
||||
Object.entries(packageInfo.assets).reduce(
|
||||
const { numOfAssets, numTransformAssets } = useMemo(
|
||||
() => ({
|
||||
numTransformAssets: getNumTransformAssets(packageInfo.assets),
|
||||
numOfAssets: Object.entries(packageInfo.assets).reduce(
|
||||
(acc, [serviceName, serviceNameValue]) =>
|
||||
acc +
|
||||
Object.entries(serviceNameValue).reduce(
|
||||
|
@ -238,6 +244,7 @@ export const SettingsPage: React.FC<Props> = memo(({ packageInfo, theme$ }: Prop
|
|||
),
|
||||
0
|
||||
),
|
||||
}),
|
||||
[packageInfo.assets]
|
||||
);
|
||||
|
||||
|
@ -351,6 +358,15 @@ export const SettingsPage: React.FC<Props> = memo(({ packageInfo, theme$ }: Prop
|
|||
</h4>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
{numTransformAssets > 0 ? (
|
||||
<>
|
||||
<TransformInstallWithCurrentUserPermissionCallout
|
||||
count={numTransformAssets}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
) : null}
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrations.settings.packageInstallDescription"
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Route } from '@kbn/shared-ux-router';
|
|||
|
||||
import type { CustomIntegration } from '@kbn/custom-integrations-plugin/common';
|
||||
|
||||
import { hasDeferredInstallations } from '../../../../../../services/has_deferred_installations';
|
||||
import { getPackageReleaseLabel } from '../../../../../../../common/services';
|
||||
|
||||
import { installationStatuses } from '../../../../../../../common/constants';
|
||||
|
@ -73,6 +74,7 @@ export const mapToCard = ({
|
|||
const version = 'version' in item ? item.version || '' : '';
|
||||
|
||||
let isUpdateAvailable = false;
|
||||
let isReauthorizationRequired = false;
|
||||
if (item.type === 'ui_link') {
|
||||
uiInternalPathUrl = item.id.includes('language_client.')
|
||||
? addBasePath(item.uiInternalPath)
|
||||
|
@ -83,6 +85,8 @@ export const mapToCard = ({
|
|||
urlVersion = item.savedObject.attributes.version || item.version;
|
||||
isUnverified = isPackageUnverified(item, packageVerificationKeyId);
|
||||
isUpdateAvailable = isPackageUpdatable(item);
|
||||
|
||||
isReauthorizationRequired = hasDeferredInstallations(item);
|
||||
}
|
||||
|
||||
const url = getHref('integration_details_overview', {
|
||||
|
@ -107,6 +111,7 @@ export const mapToCard = ({
|
|||
version,
|
||||
release,
|
||||
categories: ((item.categories || []) as string[]).filter((c: string) => !!c),
|
||||
isReauthorizationRequired,
|
||||
isUnverified,
|
||||
isUpdateAvailable,
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { Fragment } from 'react';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import {
|
||||
EuiAccordion,
|
||||
|
@ -62,7 +62,7 @@ export const CustomAssetsAccordion: FunctionComponent<CustomAssetsAccordionProps
|
|||
<EuiSpacer size="m" />
|
||||
<EuiSplitPanel.Outer hasBorder hasShadow={false}>
|
||||
{views.map((view, index) => (
|
||||
<>
|
||||
<Fragment key={index}>
|
||||
<EuiSplitPanel.Inner grow={false} key={index}>
|
||||
<EuiText size="m">
|
||||
<p>
|
||||
|
@ -78,7 +78,7 @@ export const CustomAssetsAccordion: FunctionComponent<CustomAssetsAccordionProps
|
|||
</EuiText>
|
||||
</EuiSplitPanel.Inner>
|
||||
{index + 1 < views.length && <EuiHorizontalRule margin="none" />}
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</EuiSplitPanel.Outer>
|
||||
</>
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { EuiCallOut } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React from 'react';
|
||||
import { uniqBy } from 'lodash';
|
||||
|
||||
import type { PackageInfo } from '../../common';
|
||||
|
||||
export const getNumTransformAssets = (assets?: PackageInfo['assets']) => {
|
||||
if (
|
||||
!assets ||
|
||||
!(Array.isArray(assets.elasticsearch?.transform) && assets.elasticsearch?.transform?.length > 0)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return uniqBy(assets.elasticsearch?.transform, 'file').length;
|
||||
};
|
||||
export const TransformInstallWithCurrentUserPermissionCallout: React.FunctionComponent<{
|
||||
count: number;
|
||||
}> = ({ count }) => {
|
||||
return (
|
||||
<EuiCallOut color="primary" iconType="iInCircle">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.createPackagePolicy.transformInstallWithCurrentUserPermissionCallout"
|
||||
defaultMessage="This package has {count, plural, one {one transform asset} other {# transform assets}} which will be created and started with the same roles as the user installing the package."
|
||||
values={{ count }}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
||||
};
|
|
@ -32,7 +32,7 @@ export const getHrefToObjectInKibanaApp = ({
|
|||
id,
|
||||
http,
|
||||
}: {
|
||||
type: KibanaAssetType;
|
||||
type: KibanaAssetType | undefined;
|
||||
id: string;
|
||||
http: HttpStart;
|
||||
}): undefined | string => {
|
||||
|
|
|
@ -224,6 +224,18 @@ export const sendRemovePackage = (pkgName: string, pkgVersion: string, force: bo
|
|||
});
|
||||
};
|
||||
|
||||
export const sendRequestReauthorizeTransforms = (
|
||||
pkgName: string,
|
||||
pkgVersion: string,
|
||||
transforms: Array<{ transformId: string }>
|
||||
) => {
|
||||
return sendRequest<InstallPackageResponse, FleetErrorResponse>({
|
||||
path: epmRouteService.getReauthorizeTransformsPath(pkgName, pkgVersion),
|
||||
method: 'post',
|
||||
body: { transforms },
|
||||
});
|
||||
};
|
||||
|
||||
interface UpdatePackageArgs {
|
||||
pkgName: string;
|
||||
pkgVersion: string;
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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 type { EsAssetReference } from '../../common/types';
|
||||
import type { PackageInfo } from '../types';
|
||||
|
||||
import { ElasticsearchAssetType } from '../../common/types';
|
||||
|
||||
import { hasDeferredInstallations } from './has_deferred_installations';
|
||||
|
||||
import { ExperimentalFeaturesService } from '.';
|
||||
|
||||
const mockGet = jest.spyOn(ExperimentalFeaturesService, 'get');
|
||||
|
||||
const createPackage = ({
|
||||
installedEs = [],
|
||||
}: {
|
||||
installedEs?: EsAssetReference[];
|
||||
} = {}): PackageInfo => ({
|
||||
name: 'test-package',
|
||||
description: 'Test Package',
|
||||
title: 'Test Package',
|
||||
version: '0.0.1',
|
||||
latestVersion: '0.0.1',
|
||||
release: 'experimental',
|
||||
format_version: '1.0.0',
|
||||
owner: { github: 'elastic/fleet' },
|
||||
policy_templates: [],
|
||||
// @ts-ignore
|
||||
assets: {},
|
||||
savedObject: {
|
||||
id: '1234',
|
||||
type: 'epm-package',
|
||||
references: [],
|
||||
attributes: {
|
||||
installed_kibana: [],
|
||||
installed_es: installedEs ?? [],
|
||||
es_index_patterns: {},
|
||||
name: 'test-package',
|
||||
version: '0.0.1',
|
||||
install_status: 'installed',
|
||||
install_version: '0.0.1',
|
||||
install_started_at: new Date().toString(),
|
||||
install_source: 'registry',
|
||||
verification_status: 'verified',
|
||||
verification_key_id: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('isPackageUnverified', () => {
|
||||
describe('When experimental feature is disabled', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore don't want to define all experimental features here
|
||||
mockGet.mockReturnValue({
|
||||
packageVerification: false,
|
||||
} as ReturnType<typeof ExperimentalFeaturesService['get']>);
|
||||
});
|
||||
|
||||
it('Should return false for a package with no saved object', () => {
|
||||
const noSoPkg = createPackage();
|
||||
// @ts-ignore we know pkg has savedObject but ts doesn't
|
||||
delete noSoPkg.savedObject;
|
||||
expect(hasDeferredInstallations(noSoPkg)).toEqual(false);
|
||||
});
|
||||
|
||||
it('Should return true for a package with at least one asset deferred', () => {
|
||||
const pkgWithDeferredInstallations = createPackage({
|
||||
installedEs: [
|
||||
{ id: '', type: ElasticsearchAssetType.ingestPipeline },
|
||||
{ id: '', type: ElasticsearchAssetType.transform, deferred: true },
|
||||
],
|
||||
});
|
||||
// @ts-ignore we know pkg has savedObject but ts doesn't
|
||||
expect(hasDeferredInstallations(pkgWithDeferredInstallations)).toEqual(true);
|
||||
});
|
||||
|
||||
it('Should return false for a package that has no asset deferred', () => {
|
||||
const pkgWithoutDeferredInstallations = createPackage({
|
||||
installedEs: [
|
||||
{ id: '', type: ElasticsearchAssetType.ingestPipeline },
|
||||
{ id: '', type: ElasticsearchAssetType.transform, deferred: false },
|
||||
],
|
||||
});
|
||||
expect(hasDeferredInstallations(pkgWithoutDeferredInstallations)).toEqual(false);
|
||||
});
|
||||
|
||||
it('Should return false for a package that has no asset', () => {
|
||||
const pkgWithoutDeferredInstallations = createPackage({
|
||||
installedEs: [],
|
||||
});
|
||||
expect(hasDeferredInstallations(pkgWithoutDeferredInstallations)).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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 type { PackageInfo, PackageListItem } from '../../common';
|
||||
|
||||
export const getDeferredInstallationsCnt = (pkg?: PackageInfo | PackageListItem | null): number => {
|
||||
return pkg && 'savedObject' in pkg && pkg.savedObject
|
||||
? pkg.savedObject.attributes?.installed_es?.filter((d) => d.deferred).length
|
||||
: 0;
|
||||
};
|
||||
|
||||
export const hasDeferredInstallations = (pkg?: PackageInfo | PackageListItem | null): boolean =>
|
||||
getDeferredInstallationsCnt(pkg) > 0;
|
|
@ -15,6 +15,8 @@ import type {
|
|||
import pMap from 'p-map';
|
||||
import { safeDump } from 'js-yaml';
|
||||
|
||||
import { HTTPAuthorizationHeader } from '../../../common/http_authorization_header';
|
||||
|
||||
import { fullAgentPolicyToYaml } from '../../../common/services';
|
||||
import { appContextService, agentPolicyService } from '../../services';
|
||||
import { getAgentsByKuery } from '../../services/agents';
|
||||
|
@ -175,6 +177,8 @@ export const createAgentPolicyHandler: FleetRequestHandler<
|
|||
const monitoringEnabled = request.body.monitoring_enabled;
|
||||
const { has_fleet_server: hasFleetServer, ...newPolicy } = request.body;
|
||||
const spaceId = fleetContext.spaceId;
|
||||
const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, user?.username);
|
||||
|
||||
try {
|
||||
const body: CreateAgentPolicyResponse = {
|
||||
item: await createAgentPolicyWithPackages({
|
||||
|
@ -186,6 +190,7 @@ export const createAgentPolicyHandler: FleetRequestHandler<
|
|||
monitoringEnabled,
|
||||
spaceId,
|
||||
user,
|
||||
authorizationHeader,
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
@ -12,6 +12,11 @@ import mime from 'mime-types';
|
|||
import semverValid from 'semver/functions/valid';
|
||||
import type { ResponseHeaders, KnownHeaders, HttpResponseOptions } from '@kbn/core/server';
|
||||
|
||||
import { HTTPAuthorizationHeader } from '../../../common/http_authorization_header';
|
||||
|
||||
import { generateTransformSecondaryAuthHeaders } from '../../services/api_keys/transform_api_keys';
|
||||
import { handleTransformReauthorizeAndStart } from '../../services/epm/elasticsearch/transform/reauthorize';
|
||||
|
||||
import type {
|
||||
GetInfoResponse,
|
||||
InstallPackageResponse,
|
||||
|
@ -54,12 +59,13 @@ import {
|
|||
} from '../../services/epm/packages';
|
||||
import type { BulkInstallResponse } from '../../services/epm/packages';
|
||||
import { defaultFleetErrorHandler, fleetErrorToResponseOptions, FleetError } from '../../errors';
|
||||
import { checkAllowedPackages, licenseService } from '../../services';
|
||||
import { appContextService, checkAllowedPackages, licenseService } from '../../services';
|
||||
import { getArchiveEntry } from '../../services/epm/archive/cache';
|
||||
import { getAsset } from '../../services/epm/archive/storage';
|
||||
import { getPackageUsageStats } from '../../services/epm/packages/get';
|
||||
import { updatePackage } from '../../services/epm/packages/update';
|
||||
import { getGpgKeyIdOrUndefined } from '../../services/epm/packages/package_verification';
|
||||
import type { ReauthorizeTransformRequestSchema } from '../../types';
|
||||
|
||||
const CACHE_CONTROL_10_MINUTES_HEADER: HttpResponseOptions['headers'] = {
|
||||
'cache-control': 'max-age=600',
|
||||
|
@ -282,8 +288,12 @@ export const installPackageFromRegistryHandler: FleetRequestHandler<
|
|||
const fleetContext = await context.fleet;
|
||||
const savedObjectsClient = fleetContext.internalSoClient;
|
||||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined;
|
||||
|
||||
const { pkgName, pkgVersion } = request.params;
|
||||
|
||||
const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, user?.username);
|
||||
|
||||
const spaceId = fleetContext.spaceId;
|
||||
const res = await installPackage({
|
||||
installSource: 'registry',
|
||||
|
@ -294,6 +304,7 @@ export const installPackageFromRegistryHandler: FleetRequestHandler<
|
|||
force: request.body?.force,
|
||||
ignoreConstraints: request.body?.ignore_constraints,
|
||||
prerelease: request.query?.prerelease,
|
||||
authorizationHeader,
|
||||
});
|
||||
|
||||
if (!res.error) {
|
||||
|
@ -334,6 +345,7 @@ export const bulkInstallPackagesFromRegistryHandler: FleetRequestHandler<
|
|||
const savedObjectsClient = fleetContext.internalSoClient;
|
||||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
const spaceId = fleetContext.spaceId;
|
||||
|
||||
const bulkInstalledResponses = await bulkInstallPackages({
|
||||
savedObjectsClient,
|
||||
esClient,
|
||||
|
@ -361,6 +373,7 @@ export const installPackageByUploadHandler: FleetRequestHandler<
|
|||
body: { message: 'Requires Enterprise license' },
|
||||
});
|
||||
}
|
||||
|
||||
const coreContext = await context.core;
|
||||
const fleetContext = await context.fleet;
|
||||
const savedObjectsClient = fleetContext.internalSoClient;
|
||||
|
@ -368,6 +381,10 @@ export const installPackageByUploadHandler: FleetRequestHandler<
|
|||
const contentType = request.headers['content-type'] as string; // from types it could also be string[] or undefined but this is checked later
|
||||
const archiveBuffer = Buffer.from(request.body);
|
||||
const spaceId = fleetContext.spaceId;
|
||||
const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined;
|
||||
|
||||
const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, user?.username);
|
||||
|
||||
const res = await installPackage({
|
||||
installSource: 'upload',
|
||||
savedObjectsClient,
|
||||
|
@ -375,6 +392,7 @@ export const installPackageByUploadHandler: FleetRequestHandler<
|
|||
archiveBuffer,
|
||||
spaceId,
|
||||
contentType,
|
||||
authorizationHeader,
|
||||
});
|
||||
if (!res.error) {
|
||||
const body: InstallPackageResponse = {
|
||||
|
@ -432,3 +450,60 @@ export const getVerificationKeyIdHandler: FleetRequestHandler = async (
|
|||
return defaultFleetErrorHandler({ error, response });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create transform and optionally start transform
|
||||
* Note that we want to add the current user's roles/permissions to the es-secondary-auth with a API Key.
|
||||
* If API Key has insufficient permissions, it should still create the transforms but not start it
|
||||
* Instead of failing, we need to allow package to continue installing other assets
|
||||
* and prompt for users to authorize the transforms with the appropriate permissions after package is done installing
|
||||
*/
|
||||
export const reauthorizeTransformsHandler: FleetRequestHandler<
|
||||
TypeOf<typeof InstallPackageFromRegistryRequestSchema.params>,
|
||||
TypeOf<typeof InstallPackageFromRegistryRequestSchema.query>,
|
||||
TypeOf<typeof ReauthorizeTransformRequestSchema.body>
|
||||
> = async (context, request, response) => {
|
||||
const coreContext = await context.core;
|
||||
const savedObjectsClient = (await context.fleet).internalSoClient;
|
||||
|
||||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
const { pkgName, pkgVersion } = request.params;
|
||||
const { transforms } = request.body;
|
||||
|
||||
let username;
|
||||
try {
|
||||
const user = await appContextService.getSecurity()?.authc.getCurrentUser(request);
|
||||
if (user) {
|
||||
username = user.username;
|
||||
}
|
||||
} catch (e) {
|
||||
// User might not have permission to get username, or security is not enabled, and that's okay.
|
||||
}
|
||||
|
||||
try {
|
||||
const logger = appContextService.getLogger();
|
||||
const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, username);
|
||||
const secondaryAuth = await generateTransformSecondaryAuthHeaders({
|
||||
authorizationHeader,
|
||||
logger,
|
||||
username,
|
||||
pkgName,
|
||||
pkgVersion,
|
||||
});
|
||||
|
||||
const resp = await handleTransformReauthorizeAndStart({
|
||||
esClient,
|
||||
savedObjectsClient,
|
||||
logger,
|
||||
pkgName,
|
||||
pkgVersion,
|
||||
transforms,
|
||||
secondaryAuth,
|
||||
username,
|
||||
});
|
||||
|
||||
return response.ok({ body: resp });
|
||||
} catch (error) {
|
||||
return defaultFleetErrorHandler({ error, response });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -39,6 +39,7 @@ import {
|
|||
GetStatsRequestSchema,
|
||||
UpdatePackageRequestSchema,
|
||||
UpdatePackageRequestSchemaDeprecated,
|
||||
ReauthorizeTransformRequestSchema,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
|
@ -54,6 +55,7 @@ import {
|
|||
getStatsHandler,
|
||||
updatePackageHandler,
|
||||
getVerificationKeyIdHandler,
|
||||
reauthorizeTransformsHandler,
|
||||
} from './handlers';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 104857600; // 100MB
|
||||
|
@ -294,4 +296,26 @@ export const registerRoutes = (router: FleetAuthzRouter) => {
|
|||
return resp;
|
||||
}
|
||||
);
|
||||
|
||||
// Update transforms with es-secondary-authorization headers,
|
||||
// append authorized_by to transform's _meta, and start transforms
|
||||
router.post(
|
||||
{
|
||||
path: EPM_API_ROUTES.REAUTHORIZE_TRANSFORMS,
|
||||
validate: ReauthorizeTransformRequestSchema,
|
||||
fleetAuthz: {
|
||||
integrations: { installPackages: true },
|
||||
packagePrivileges: {
|
||||
transform: {
|
||||
actions: {
|
||||
canStartStopTransform: {
|
||||
executePackageAction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
reauthorizeTransformsHandler
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,6 +13,8 @@ import type { RequestHandler } from '@kbn/core/server';
|
|||
|
||||
import { groupBy, keyBy } from 'lodash';
|
||||
|
||||
import { HTTPAuthorizationHeader } from '../../../common/http_authorization_header';
|
||||
|
||||
import { populatePackagePolicyAssignedAgentsCount } from '../../services/package_policies/populate_package_policy_assigned_agents_count';
|
||||
|
||||
import {
|
||||
|
@ -219,6 +221,8 @@ export const createPackagePolicyHandler: FleetRequestHandler<
|
|||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
const user = appContextService.getSecurity()?.authc.getCurrentUser(request) || undefined;
|
||||
const { force, package: pkg, ...newPolicy } = request.body;
|
||||
const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, user?.username);
|
||||
|
||||
if ('output_id' in newPolicy) {
|
||||
// TODO Remove deprecated APIs https://github.com/elastic/kibana/issues/121485
|
||||
delete newPolicy.output_id;
|
||||
|
@ -248,6 +252,7 @@ export const createPackagePolicyHandler: FleetRequestHandler<
|
|||
}
|
||||
|
||||
// Create package policy
|
||||
|
||||
const packagePolicy = await fleetContext.packagePolicyService.asCurrentUser.create(
|
||||
soClient,
|
||||
esClient,
|
||||
|
@ -256,6 +261,7 @@ export const createPackagePolicyHandler: FleetRequestHandler<
|
|||
user,
|
||||
force,
|
||||
spaceId,
|
||||
authorizationHeader,
|
||||
},
|
||||
context,
|
||||
request
|
||||
|
|
|
@ -243,6 +243,7 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({
|
|||
id: { type: 'keyword' },
|
||||
type: { type: 'keyword' },
|
||||
version: { type: 'keyword' },
|
||||
deferred: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
installed_kibana: {
|
||||
|
|
|
@ -22,6 +22,8 @@ import type { BulkResponseItem } from '@elastic/elasticsearch/lib/api/typesWithB
|
|||
|
||||
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants';
|
||||
|
||||
import type { HTTPAuthorizationHeader } from '../../common/http_authorization_header';
|
||||
|
||||
import {
|
||||
AGENT_POLICY_SAVED_OBJECT_TYPE,
|
||||
AGENTS_PREFIX,
|
||||
|
@ -209,7 +211,11 @@ class AgentPolicyService {
|
|||
soClient: SavedObjectsClientContract,
|
||||
esClient: ElasticsearchClient,
|
||||
agentPolicy: NewAgentPolicy,
|
||||
options: { id?: string; user?: AuthenticatedUser } = {}
|
||||
options: {
|
||||
id?: string;
|
||||
user?: AuthenticatedUser;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
} = {}
|
||||
): Promise<AgentPolicy> {
|
||||
// Ensure an ID is provided, so we can include it in the audit logs below
|
||||
if (!options.id) {
|
||||
|
@ -444,7 +450,12 @@ class AgentPolicyService {
|
|||
esClient: ElasticsearchClient,
|
||||
id: string,
|
||||
agentPolicy: Partial<AgentPolicy>,
|
||||
options?: { user?: AuthenticatedUser; force?: boolean; spaceId?: string }
|
||||
options?: {
|
||||
user?: AuthenticatedUser;
|
||||
force?: boolean;
|
||||
spaceId?: string;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}
|
||||
): Promise<AgentPolicy> {
|
||||
if (agentPolicy.name) {
|
||||
await this.requireUniqueName(soClient, {
|
||||
|
@ -479,6 +490,7 @@ class AgentPolicyService {
|
|||
esClient,
|
||||
packagesToInstall,
|
||||
spaceId: options?.spaceId || DEFAULT_SPACE_ID,
|
||||
authorizationHeader: options?.authorizationHeader,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/
|
|||
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/common/model';
|
||||
|
||||
import type { HTTPAuthorizationHeader } from '../../common/http_authorization_header';
|
||||
|
||||
import {
|
||||
FLEET_ELASTIC_AGENT_PACKAGE,
|
||||
FLEET_SERVER_PACKAGE,
|
||||
|
@ -48,7 +50,11 @@ async function createPackagePolicy(
|
|||
esClient: ElasticsearchClient,
|
||||
agentPolicy: AgentPolicy,
|
||||
packageToInstall: string,
|
||||
options: { spaceId: string; user: AuthenticatedUser | undefined }
|
||||
options: {
|
||||
spaceId: string;
|
||||
user: AuthenticatedUser | undefined;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}
|
||||
) {
|
||||
const newPackagePolicy = await packagePolicyService
|
||||
.buildPackagePolicyFromPackage(soClient, packageToInstall)
|
||||
|
@ -71,6 +77,7 @@ async function createPackagePolicy(
|
|||
spaceId: options.spaceId,
|
||||
user: options.user,
|
||||
bumpRevision: false,
|
||||
authorizationHeader: options.authorizationHeader,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -83,6 +90,7 @@ interface CreateAgentPolicyParams {
|
|||
monitoringEnabled?: string[];
|
||||
spaceId: string;
|
||||
user?: AuthenticatedUser;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}
|
||||
|
||||
export async function createAgentPolicyWithPackages({
|
||||
|
@ -94,6 +102,7 @@ export async function createAgentPolicyWithPackages({
|
|||
monitoringEnabled,
|
||||
spaceId,
|
||||
user,
|
||||
authorizationHeader,
|
||||
}: CreateAgentPolicyParams) {
|
||||
let agentPolicyId = newPolicy.id;
|
||||
const packagesToInstall = [];
|
||||
|
@ -118,6 +127,7 @@ export async function createAgentPolicyWithPackages({
|
|||
esClient,
|
||||
packagesToInstall,
|
||||
spaceId,
|
||||
authorizationHeader,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -126,6 +136,7 @@ export async function createAgentPolicyWithPackages({
|
|||
const agentPolicy = await agentPolicyService.create(soClient, esClient, policy, {
|
||||
user,
|
||||
id: agentPolicyId,
|
||||
authorizationHeader,
|
||||
});
|
||||
|
||||
// Create the fleet server package policy and add it to agent policy.
|
||||
|
@ -133,6 +144,7 @@ export async function createAgentPolicyWithPackages({
|
|||
await createPackagePolicy(soClient, esClient, agentPolicy, FLEET_SERVER_PACKAGE, {
|
||||
spaceId,
|
||||
user,
|
||||
authorizationHeader,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -141,6 +153,7 @@ export async function createAgentPolicyWithPackages({
|
|||
await createPackagePolicy(soClient, esClient, agentPolicy, FLEET_SYSTEM_PACKAGE, {
|
||||
spaceId,
|
||||
user,
|
||||
authorizationHeader,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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 type { CreateAPIKeyParams } from '@kbn/security-plugin/server';
|
||||
import type { FakeRawRequest, Headers } from '@kbn/core-http-server';
|
||||
import { CoreKibanaRequest } from '@kbn/core-http-router-server-internal';
|
||||
|
||||
import type { Logger } from '@kbn/logging';
|
||||
|
||||
import { appContextService } from '..';
|
||||
|
||||
import type { HTTPAuthorizationHeader } from '../../../common/http_authorization_header';
|
||||
|
||||
import type {
|
||||
TransformAPIKey,
|
||||
SecondaryAuthorizationHeader,
|
||||
} from '../../../common/types/models/transform_api_key';
|
||||
|
||||
export function isTransformApiKey(arg: any): arg is TransformAPIKey {
|
||||
return (
|
||||
arg &&
|
||||
arg.hasOwnProperty('api_key') &&
|
||||
arg.hasOwnProperty('encoded') &&
|
||||
typeof arg.encoded === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function createKibanaRequestFromAuth(authorizationHeader: HTTPAuthorizationHeader) {
|
||||
const requestHeaders: Headers = {
|
||||
authorization: authorizationHeader.toString(),
|
||||
};
|
||||
const fakeRawRequest: FakeRawRequest = {
|
||||
headers: requestHeaders,
|
||||
path: '/',
|
||||
};
|
||||
|
||||
// Since we're using API keys and accessing elasticsearch can only be done
|
||||
// via a request, we're faking one with the proper authorization headers.
|
||||
const fakeRequest = CoreKibanaRequest.from(fakeRawRequest);
|
||||
|
||||
return fakeRequest;
|
||||
}
|
||||
|
||||
/** This function generates a new API based on current Kibana's user request.headers.authorization
|
||||
* then formats it into a es-secondary-authorization header object
|
||||
* @param authorizationHeader:
|
||||
* @param createParams
|
||||
*/
|
||||
export async function generateTransformSecondaryAuthHeaders({
|
||||
authorizationHeader,
|
||||
createParams,
|
||||
logger,
|
||||
username,
|
||||
pkgName,
|
||||
pkgVersion,
|
||||
}: {
|
||||
authorizationHeader: HTTPAuthorizationHeader | null | undefined;
|
||||
logger: Logger;
|
||||
createParams?: CreateAPIKeyParams;
|
||||
username?: string;
|
||||
pkgName?: string;
|
||||
pkgVersion?: string;
|
||||
}): Promise<SecondaryAuthorizationHeader | undefined> {
|
||||
if (!authorizationHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fakeKibanaRequest = createKibanaRequestFromAuth(authorizationHeader);
|
||||
|
||||
const user = username ?? authorizationHeader.getUsername();
|
||||
|
||||
const name = pkgName
|
||||
? `${pkgName}${pkgVersion ? '-' + pkgVersion : ''}-transform${user ? '-by-' + user : ''}`
|
||||
: `fleet-transform-api-key`;
|
||||
|
||||
const security = appContextService.getSecurity();
|
||||
|
||||
// If security is not enabled or available, we can't generate api key
|
||||
// but that's ok, cause all the index and transform commands should work
|
||||
if (!security) return;
|
||||
|
||||
try {
|
||||
const apiKeyWithCurrentUserPermission = await security?.authc.apiKeys.grantAsInternalUser(
|
||||
fakeKibanaRequest,
|
||||
createParams ?? {
|
||||
name,
|
||||
metadata: {
|
||||
managed_by: 'fleet',
|
||||
managed: true,
|
||||
type: 'transform',
|
||||
},
|
||||
role_descriptors: {},
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug(`Created api_key name: ${name}`);
|
||||
let encodedApiKey: TransformAPIKey['encoded'] | null = null;
|
||||
|
||||
// Property 'encoded' does exist in the resp coming back from request
|
||||
// and is required to use in authentication headers
|
||||
// It's just not defined in returned GrantAPIKeyResult type
|
||||
if (isTransformApiKey(apiKeyWithCurrentUserPermission)) {
|
||||
encodedApiKey = apiKeyWithCurrentUserPermission.encoded;
|
||||
}
|
||||
|
||||
const secondaryAuth =
|
||||
encodedApiKey !== null
|
||||
? {
|
||||
headers: {
|
||||
'es-secondary-authorization': `ApiKey ${encodedApiKey}`,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return secondaryAuth;
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to create api_key: ${name} because ${e}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -9,6 +9,8 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
|||
|
||||
import type { IndicesIndexSettings } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
|
||||
import { appContextService } from '../../..';
|
||||
|
||||
import { retryTransientEsErrors } from '../retry';
|
||||
|
||||
export async function updateIndexSettings(
|
||||
|
@ -16,6 +18,8 @@ export async function updateIndexSettings(
|
|||
index: string,
|
||||
settings: IndicesIndexSettings
|
||||
): Promise<void> {
|
||||
const logger = appContextService.getLogger();
|
||||
|
||||
if (index) {
|
||||
try {
|
||||
await retryTransientEsErrors(() =>
|
||||
|
@ -25,7 +29,8 @@ export async function updateIndexSettings(
|
|||
})
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(`could not update index settings for ${index}`);
|
||||
// No need to throw error and block installation process
|
||||
logger.debug(`Could not update index settings for ${index} because ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,12 @@ import { safeLoad } from 'js-yaml';
|
|||
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
|
||||
import { uniqBy } from 'lodash';
|
||||
|
||||
import type { HTTPAuthorizationHeader } from '../../../../../common/http_authorization_header';
|
||||
|
||||
import type { SecondaryAuthorizationHeader } from '../../../../../common/types/models/transform_api_key';
|
||||
|
||||
import { generateTransformSecondaryAuthHeaders } from '../../../api_keys/transform_api_keys';
|
||||
|
||||
import {
|
||||
PACKAGE_TEMPLATE_SUFFIX,
|
||||
USER_SETTINGS_TEMPLATE_SUFFIX,
|
||||
|
@ -36,7 +42,8 @@ import { getInstallation } from '../../packages';
|
|||
import { retryTransientEsErrors } from '../retry';
|
||||
|
||||
import { deleteTransforms } from './remove';
|
||||
import { getAsset, TRANSFORM_DEST_IDX_ALIAS_LATEST_SFX } from './common';
|
||||
import { getAsset } from './common';
|
||||
import { getDestinationIndexAliases } from './transform_utils';
|
||||
|
||||
const DEFAULT_TRANSFORM_TEMPLATES_PRIORITY = 250;
|
||||
enum TRANSFORM_SPECS_TYPES {
|
||||
|
@ -58,6 +65,7 @@ interface TransformInstallation extends TransformModuleBase {
|
|||
content: any;
|
||||
transformVersion?: string;
|
||||
installationOrder?: number;
|
||||
runAsKibanaSystem?: boolean;
|
||||
}
|
||||
|
||||
const installLegacyTransformsAssets = async (
|
||||
|
@ -137,7 +145,8 @@ const processTransformAssetsPerModule = (
|
|||
installablePackage: InstallablePackage,
|
||||
installNameSuffix: string,
|
||||
transformPaths: string[],
|
||||
previousInstalledTransformEsAssets: EsAssetReference[] = []
|
||||
previousInstalledTransformEsAssets: EsAssetReference[] = [],
|
||||
username?: string
|
||||
) => {
|
||||
const transformsSpecifications = new Map();
|
||||
const destinationIndexTemplates: DestinationIndexTemplateInstallation[] = [];
|
||||
|
@ -195,10 +204,6 @@ const processTransformAssetsPerModule = (
|
|||
const installationOrder =
|
||||
isFinite(content._meta?.order) && content._meta?.order >= 0 ? content._meta?.order : 0;
|
||||
const transformVersion = content._meta?.fleet_transform_version ?? '0.1.0';
|
||||
// The “all” alias for the transform destination indices will be adjusted to include the new transform destination index as well as everything it previously included
|
||||
const allIndexAliasName = `${content.dest.index}.all`;
|
||||
// The “latest” alias for the transform destination indices will point solely to the new transform destination index
|
||||
const latestIndexAliasName = `${content.dest.index}.latest`;
|
||||
|
||||
transformsSpecifications
|
||||
.get(transformModuleId)
|
||||
|
@ -206,24 +211,30 @@ const processTransformAssetsPerModule = (
|
|||
|
||||
// Create two aliases associated with the destination index
|
||||
// for better handling during upgrades
|
||||
const alias = {
|
||||
[allIndexAliasName]: {},
|
||||
[latestIndexAliasName]: {},
|
||||
};
|
||||
const aliases = getDestinationIndexAliases(content.dest.aliases);
|
||||
const aliasNames = aliases.map((a) => a.alias);
|
||||
// Override yml settings with alia format for transform's dest.aliases
|
||||
content.dest.aliases = aliases;
|
||||
|
||||
const versionedIndexName = `${content.dest.index}-${installNameSuffix}`;
|
||||
content.dest.index = versionedIndexName;
|
||||
indicesToAddRefs.push({
|
||||
id: versionedIndexName,
|
||||
id: content.dest.index,
|
||||
type: ElasticsearchAssetType.index,
|
||||
});
|
||||
|
||||
// If run_as_kibana_system is not set, or is set to true, then run as kibana_system user
|
||||
// else, run with user's secondary credentials
|
||||
const runAsKibanaSystem = content._meta?.run_as_kibana_system !== false;
|
||||
|
||||
transformsSpecifications.get(transformModuleId)?.set('destinationIndex', content.dest);
|
||||
transformsSpecifications.get(transformModuleId)?.set('destinationIndexAlias', alias);
|
||||
transformsSpecifications.get(transformModuleId)?.set('destinationIndexAlias', aliases);
|
||||
transformsSpecifications.get(transformModuleId)?.set('transform', content);
|
||||
transformsSpecifications.get(transformModuleId)?.set('transformVersion', transformVersion);
|
||||
|
||||
content._meta = {
|
||||
...(content._meta ?? {}),
|
||||
...getESAssetMetadata({ packageName: installablePackage.name }),
|
||||
...(username ? { installed_by: username } : {}),
|
||||
run_as_kibana_system: runAsKibanaSystem,
|
||||
};
|
||||
|
||||
const installationName = getTransformAssetNameForInstallation(
|
||||
|
@ -236,13 +247,14 @@ const processTransformAssetsPerModule = (
|
|||
const currentTransformSameAsPrev =
|
||||
previousInstalledTransformEsAssets.find((t) => t.id === installationName) !== undefined;
|
||||
if (previousInstalledTransformEsAssets.length === 0) {
|
||||
aliasesRefs.push(allIndexAliasName, latestIndexAliasName);
|
||||
aliasesRefs.push(...aliasNames);
|
||||
transforms.push({
|
||||
transformModuleId,
|
||||
installationName,
|
||||
installationOrder,
|
||||
transformVersion,
|
||||
content,
|
||||
runAsKibanaSystem,
|
||||
});
|
||||
transformsSpecifications.get(transformModuleId)?.set('transformVersionChanged', true);
|
||||
} else {
|
||||
|
@ -277,9 +289,12 @@ const processTransformAssetsPerModule = (
|
|||
installationOrder,
|
||||
transformVersion,
|
||||
content,
|
||||
runAsKibanaSystem,
|
||||
});
|
||||
transformsSpecifications.get(transformModuleId)?.set('transformVersionChanged', true);
|
||||
aliasesRefs.push(allIndexAliasName, latestIndexAliasName);
|
||||
if (aliasNames.length > 0) {
|
||||
aliasesRefs.push(...aliasNames);
|
||||
}
|
||||
} else {
|
||||
transformsSpecifications.get(transformModuleId)?.set('transformVersionChanged', false);
|
||||
}
|
||||
|
@ -371,9 +386,12 @@ const installTransformsAssets = async (
|
|||
savedObjectsClient: SavedObjectsClientContract,
|
||||
logger: Logger,
|
||||
esReferences: EsAssetReference[] = [],
|
||||
previousInstalledTransformEsAssets: EsAssetReference[] = []
|
||||
previousInstalledTransformEsAssets: EsAssetReference[] = [],
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null
|
||||
) => {
|
||||
let installedTransforms: EsAssetReference[] = [];
|
||||
const username = authorizationHeader?.getUsername();
|
||||
|
||||
if (transformPaths.length > 0) {
|
||||
const {
|
||||
indicesToAddRefs,
|
||||
|
@ -383,23 +401,27 @@ const installTransformsAssets = async (
|
|||
transforms,
|
||||
destinationIndexTemplates,
|
||||
transformsSpecifications,
|
||||
aliasesRefs,
|
||||
transformsToRemove,
|
||||
transformsToRemoveWithDestIndex,
|
||||
} = processTransformAssetsPerModule(
|
||||
installablePackage,
|
||||
installNameSuffix,
|
||||
transformPaths,
|
||||
previousInstalledTransformEsAssets
|
||||
previousInstalledTransformEsAssets,
|
||||
username
|
||||
);
|
||||
|
||||
// ensure the .latest alias points to only the latest
|
||||
// by removing any associate of old destination indices
|
||||
await Promise.all(
|
||||
aliasesRefs
|
||||
.filter((a) => a.endsWith(TRANSFORM_DEST_IDX_ALIAS_LATEST_SFX))
|
||||
.map((alias) => deleteAliasFromIndices({ esClient, logger, alias }))
|
||||
);
|
||||
// By default, for internal Elastic packages that touch system indices, we want to run as internal user
|
||||
// so we set runAsKibanaSystem: true by default (e.g. when run_as_kibana_system set to true/not defined in yml file).
|
||||
// If package should be installed as the logged in user, set run_as_kibana_system: false,
|
||||
// and pass es-secondary-authorization in header when creating the transforms.
|
||||
const secondaryAuth = await generateTransformSecondaryAuthHeaders({
|
||||
authorizationHeader,
|
||||
logger,
|
||||
pkgName: installablePackage.name,
|
||||
pkgVersion: installablePackage.version,
|
||||
username,
|
||||
});
|
||||
|
||||
// delete all previous transform
|
||||
await Promise.all([
|
||||
|
@ -407,13 +429,15 @@ const installTransformsAssets = async (
|
|||
esClient,
|
||||
transformsToRemoveWithDestIndex.map((asset) => asset.id),
|
||||
// Delete destination indices if specified or if from old json schema
|
||||
true
|
||||
true,
|
||||
secondaryAuth
|
||||
),
|
||||
deleteTransforms(
|
||||
esClient,
|
||||
transformsToRemove.map((asset) => asset.id),
|
||||
// Else, keep destination indices by default
|
||||
false
|
||||
false,
|
||||
secondaryAuth
|
||||
),
|
||||
]);
|
||||
|
||||
|
@ -492,58 +516,6 @@ const installTransformsAssets = async (
|
|||
.filter((p) => p !== undefined)
|
||||
);
|
||||
|
||||
// create destination indices
|
||||
await Promise.all(
|
||||
transforms.map(async (transform) => {
|
||||
const index = transform.content.dest.index;
|
||||
|
||||
const aliases = transformsSpecifications
|
||||
.get(transform.transformModuleId)
|
||||
?.get('destinationIndexAlias');
|
||||
try {
|
||||
const resp = await retryTransientEsErrors(
|
||||
() =>
|
||||
esClient.indices.create(
|
||||
{
|
||||
index,
|
||||
aliases,
|
||||
},
|
||||
{ ignore: [400] }
|
||||
),
|
||||
{ logger }
|
||||
);
|
||||
logger.debug(`Created destination index: ${index}`);
|
||||
|
||||
// If index already exists, we still need to update the destination index alias
|
||||
// to point '{destinationIndexName}.latest' to the versioned index
|
||||
// @ts-ignore status is a valid field of resp
|
||||
if (resp.status === 400 && aliases) {
|
||||
await retryTransientEsErrors(
|
||||
() =>
|
||||
esClient.indices.updateAliases({
|
||||
body: {
|
||||
actions: Object.keys(aliases).map((alias) => ({ add: { index, alias } })),
|
||||
},
|
||||
}),
|
||||
{ logger }
|
||||
);
|
||||
logger.debug(`Created aliases for destination index: ${index}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error creating destination index: ${JSON.stringify({
|
||||
index,
|
||||
aliases: transformsSpecifications
|
||||
.get(transform.transformModuleId)
|
||||
?.get('destinationIndexAlias'),
|
||||
})} with error ${err}`
|
||||
);
|
||||
|
||||
throw new Error(err.message);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// If the transforms have specific installation order, install & optionally start transforms sequentially
|
||||
const shouldInstallSequentially =
|
||||
uniqBy(transforms, 'installationOrder').length === transforms.length;
|
||||
|
@ -555,6 +527,7 @@ const installTransformsAssets = async (
|
|||
logger,
|
||||
transform,
|
||||
startTransform: transformsSpecifications.get(transform.transformModuleId)?.get('start'),
|
||||
secondaryAuth: transform.runAsKibanaSystem !== false ? undefined : secondaryAuth,
|
||||
});
|
||||
installedTransforms.push(installTransform);
|
||||
}
|
||||
|
@ -566,22 +539,42 @@ const installTransformsAssets = async (
|
|||
logger,
|
||||
transform,
|
||||
startTransform: transformsSpecifications.get(transform.transformModuleId)?.get('start'),
|
||||
secondaryAuth: transform.runAsKibanaSystem !== false ? undefined : secondaryAuth,
|
||||
});
|
||||
});
|
||||
|
||||
installedTransforms = await Promise.all(transformsPromises).then((results) => results.flat());
|
||||
}
|
||||
|
||||
// If user does not have sufficient permissions to start the transforms,
|
||||
// we need to mark them as deferred installations without blocking full package installation
|
||||
// so that they can be updated/re-authorized later
|
||||
|
||||
if (installedTransforms.length > 0) {
|
||||
// get and save refs associated with the transforms before installing
|
||||
esReferences = await updateEsAssetReferences(
|
||||
savedObjectsClient,
|
||||
installablePackage.name,
|
||||
esReferences,
|
||||
{
|
||||
assetsToRemove: installedTransforms,
|
||||
assetsToAdd: installedTransforms,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { installedTransforms, esReferences };
|
||||
};
|
||||
|
||||
export const installTransforms = async (
|
||||
installablePackage: InstallablePackage,
|
||||
paths: string[],
|
||||
esClient: ElasticsearchClient,
|
||||
savedObjectsClient: SavedObjectsClientContract,
|
||||
logger: Logger,
|
||||
esReferences?: EsAssetReference[]
|
||||
esReferences?: EsAssetReference[],
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null
|
||||
) => {
|
||||
const transformPaths = paths.filter((path) => isTransform(path));
|
||||
|
||||
|
@ -628,7 +621,8 @@ export const installTransforms = async (
|
|||
savedObjectsClient,
|
||||
logger,
|
||||
esReferences,
|
||||
previousInstalledTransformEsAssets
|
||||
previousInstalledTransformEsAssets,
|
||||
authorizationHeader
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -637,65 +631,59 @@ export const isTransform = (path: string) => {
|
|||
return !path.endsWith('/') && pathParts.type === ElasticsearchAssetType.transform;
|
||||
};
|
||||
|
||||
async function deleteAliasFromIndices({
|
||||
esClient,
|
||||
logger,
|
||||
alias,
|
||||
}: {
|
||||
esClient: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
alias: string;
|
||||
}) {
|
||||
try {
|
||||
const resp = await esClient.indices.getAlias({ name: alias });
|
||||
const indicesMatchingAlias = Object.keys(resp);
|
||||
logger.debug(`Deleting alias: '${alias}' matching indices ${indicesMatchingAlias}`);
|
||||
|
||||
if (indicesMatchingAlias.length > 0) {
|
||||
await retryTransientEsErrors(
|
||||
() =>
|
||||
// defer validation on put if the source index is not available
|
||||
esClient.indices.deleteAlias(
|
||||
{ index: indicesMatchingAlias, name: alias },
|
||||
{ ignore: [404] }
|
||||
),
|
||||
{ logger }
|
||||
);
|
||||
logger.debug(`Deleted alias: '${alias}' matching indices ${indicesMatchingAlias}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error deleting alias: ${alias}`);
|
||||
}
|
||||
interface TransformEsAssetReference extends EsAssetReference {
|
||||
version?: string;
|
||||
}
|
||||
/**
|
||||
* Create transform and optionally start transform
|
||||
* Note that we want to add the current user's roles/permissions to the es-secondary-auth with a API Key.
|
||||
* If API Key has insufficient permissions, it should still create the transforms but not start it
|
||||
* Instead of failing, we need to allow package to continue installing other assets
|
||||
* and prompt for users to authorize the transforms with the appropriate permissions after package is done installing
|
||||
*/
|
||||
async function handleTransformInstall({
|
||||
esClient,
|
||||
logger,
|
||||
transform,
|
||||
startTransform,
|
||||
secondaryAuth,
|
||||
}: {
|
||||
esClient: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
transform: TransformInstallation;
|
||||
startTransform?: boolean;
|
||||
}): Promise<EsAssetReference> {
|
||||
secondaryAuth?: SecondaryAuthorizationHeader;
|
||||
}): Promise<TransformEsAssetReference> {
|
||||
let isUnauthorizedAPIKey = false;
|
||||
try {
|
||||
await retryTransientEsErrors(
|
||||
() =>
|
||||
// defer validation on put if the source index is not available
|
||||
esClient.transform.putTransform({
|
||||
transform_id: transform.installationName,
|
||||
defer_validation: true,
|
||||
body: transform.content,
|
||||
}),
|
||||
// defer_validation: true on put if the source index is not available
|
||||
// but will check if API Key has sufficient permission
|
||||
esClient.transform.putTransform(
|
||||
{
|
||||
transform_id: transform.installationName,
|
||||
defer_validation: true,
|
||||
body: transform.content,
|
||||
},
|
||||
// add '{ headers: { es-secondary-authorization: 'ApiKey {encodedApiKey}' } }'
|
||||
secondaryAuth ? { ...secondaryAuth } : undefined
|
||||
),
|
||||
{ logger }
|
||||
);
|
||||
logger.debug(`Created transform: ${transform.installationName}`);
|
||||
} catch (err) {
|
||||
// swallow the error if the transform already exists.
|
||||
const isResponseError = err instanceof errors.ResponseError;
|
||||
isUnauthorizedAPIKey =
|
||||
isResponseError &&
|
||||
err?.body?.error?.type === 'security_exception' &&
|
||||
err?.body?.error?.reason?.includes('unauthorized for API key');
|
||||
|
||||
const isAlreadyExistError =
|
||||
err instanceof errors.ResponseError &&
|
||||
err?.body?.error?.type === 'resource_already_exists_exception';
|
||||
if (!isAlreadyExistError) {
|
||||
isResponseError && err?.body?.error?.type === 'resource_already_exists_exception';
|
||||
|
||||
// swallow the error if the transform already exists or if API key has insufficient permissions
|
||||
if (!isUnauthorizedAPIKey && !isAlreadyExistError) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
@ -703,18 +691,71 @@ async function handleTransformInstall({
|
|||
// start transform by default if not set in yml file
|
||||
// else, respect the setting
|
||||
if (startTransform === undefined || startTransform === true) {
|
||||
await retryTransientEsErrors(
|
||||
() =>
|
||||
esClient.transform.startTransform(
|
||||
{ transform_id: transform.installationName },
|
||||
{ ignore: [409] }
|
||||
),
|
||||
{ logger, additionalResponseStatuses: [400] }
|
||||
);
|
||||
logger.debug(`Started transform: ${transform.installationName}`);
|
||||
try {
|
||||
await retryTransientEsErrors(
|
||||
() =>
|
||||
esClient.transform.startTransform(
|
||||
{ transform_id: transform.installationName },
|
||||
{ ignore: [409] }
|
||||
),
|
||||
{ logger, additionalResponseStatuses: [400] }
|
||||
);
|
||||
logger.debug(`Started transform: ${transform.installationName}`);
|
||||
} catch (err) {
|
||||
const isResponseError = err instanceof errors.ResponseError;
|
||||
isUnauthorizedAPIKey =
|
||||
isResponseError &&
|
||||
// if transform was created with insufficient permission,
|
||||
// _start will yield an error
|
||||
err?.body?.error?.type === 'security_exception' &&
|
||||
err?.body?.error?.reason?.includes('lacks the required permissions');
|
||||
|
||||
// swallow the error if the transform can't be started if API key has insufficient permissions
|
||||
if (!isUnauthorizedAPIKey) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if transform was not set to start automatically in yml config,
|
||||
// we need to check using _stats if the transform had insufficient permissions
|
||||
try {
|
||||
const transformStats = await retryTransientEsErrors(
|
||||
() =>
|
||||
esClient.transform.getTransformStats(
|
||||
{ transform_id: transform.installationName },
|
||||
{ ignore: [409] }
|
||||
),
|
||||
{ logger, additionalResponseStatuses: [400] }
|
||||
);
|
||||
if (Array.isArray(transformStats.transforms) && transformStats.transforms.length === 1) {
|
||||
// @ts-expect-error TransformGetTransformStatsTransformStats should have 'health'
|
||||
const transformHealth = transformStats.transforms[0].health;
|
||||
if (
|
||||
transformHealth.status === 'red' &&
|
||||
Array.isArray(transformHealth.issues) &&
|
||||
transformHealth.issues.find(
|
||||
(i: { issue: string }) => i.issue === 'Privileges check failed'
|
||||
)
|
||||
) {
|
||||
isUnauthorizedAPIKey = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`Error getting transform stats for transform: ${transform.installationName} cause ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { id: transform.installationName, type: ElasticsearchAssetType.transform };
|
||||
return {
|
||||
id: transform.installationName,
|
||||
type: ElasticsearchAssetType.transform,
|
||||
// If isUnauthorizedAPIKey: true (due to insufficient user permission at transform creation)
|
||||
// that means the transform is created but not started.
|
||||
// Note in saved object this is a deferred installation so user can later reauthorize
|
||||
deferred: isUnauthorizedAPIKey,
|
||||
version: transform.transformVersion,
|
||||
};
|
||||
}
|
||||
|
||||
const getLegacyTransformNameForInstallation = (
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* 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 type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
|
||||
import { sortBy, uniqBy } from 'lodash';
|
||||
|
||||
import type { SecondaryAuthorizationHeader } from '../../../../../common/types/models/transform_api_key';
|
||||
import { updateEsAssetReferences } from '../../packages/install';
|
||||
import type { Installation } from '../../../../../common';
|
||||
import { ElasticsearchAssetType, PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common';
|
||||
|
||||
import { retryTransientEsErrors } from '../retry';
|
||||
|
||||
interface FleetTransformMetadata {
|
||||
fleet_transform_version?: string;
|
||||
order?: number;
|
||||
package?: { name: string };
|
||||
managed?: boolean;
|
||||
managed_by?: string;
|
||||
installed_by?: string;
|
||||
last_authorized_by?: string;
|
||||
transformId: string;
|
||||
}
|
||||
|
||||
async function reauthorizeAndStartTransform({
|
||||
esClient,
|
||||
logger,
|
||||
transformId,
|
||||
secondaryAuth,
|
||||
meta,
|
||||
}: {
|
||||
esClient: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
transformId: string;
|
||||
secondaryAuth?: SecondaryAuthorizationHeader;
|
||||
shouldInstallSequentially?: boolean;
|
||||
meta?: object;
|
||||
}): Promise<{ transformId: string; success: boolean; error: null | any }> {
|
||||
try {
|
||||
await retryTransientEsErrors(
|
||||
() =>
|
||||
esClient.transform.updateTransform(
|
||||
{
|
||||
transform_id: transformId,
|
||||
body: { _meta: meta },
|
||||
},
|
||||
{ ...(secondaryAuth ? secondaryAuth : {}) }
|
||||
),
|
||||
{ logger, additionalResponseStatuses: [400] }
|
||||
);
|
||||
|
||||
logger.debug(`Updated transform: ${transformId}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update transform: ${transformId} because ${err}`);
|
||||
return { transformId, success: false, error: err };
|
||||
}
|
||||
|
||||
try {
|
||||
const startedTransform = await retryTransientEsErrors(
|
||||
() => esClient.transform.startTransform({ transform_id: transformId }, { ignore: [409] }),
|
||||
{ logger, additionalResponseStatuses: [400] }
|
||||
);
|
||||
logger.debug(`Started transform: ${transformId}`);
|
||||
return { transformId, success: startedTransform.acknowledged, error: null };
|
||||
} catch (err) {
|
||||
logger.error(`Failed to start transform: ${transformId} because ${err}`);
|
||||
return { transformId, success: false, error: err };
|
||||
}
|
||||
}
|
||||
export async function handleTransformReauthorizeAndStart({
|
||||
esClient,
|
||||
savedObjectsClient,
|
||||
logger,
|
||||
pkgName,
|
||||
pkgVersion,
|
||||
transforms,
|
||||
secondaryAuth,
|
||||
username,
|
||||
}: {
|
||||
esClient: ElasticsearchClient;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
logger: Logger;
|
||||
transforms: Array<{ transformId: string }>;
|
||||
pkgName: string;
|
||||
pkgVersion?: string;
|
||||
secondaryAuth?: SecondaryAuthorizationHeader;
|
||||
username?: string;
|
||||
}) {
|
||||
if (!secondaryAuth) {
|
||||
throw Error(
|
||||
'A valid secondary authorization with sufficient `manage_transform` permission is needed to re-authorize and start transforms. ' +
|
||||
'This could be because security is not enabled, or API key cannot be generated.'
|
||||
);
|
||||
}
|
||||
|
||||
const transformInfos = await Promise.all(
|
||||
transforms.map(({ transformId }) =>
|
||||
retryTransientEsErrors(
|
||||
() =>
|
||||
esClient.transform.getTransform(
|
||||
{
|
||||
transform_id: transformId,
|
||||
},
|
||||
{ ...(secondaryAuth ? secondaryAuth : {}) }
|
||||
),
|
||||
{ logger, additionalResponseStatuses: [400] }
|
||||
)
|
||||
)
|
||||
);
|
||||
const transformsMetadata: FleetTransformMetadata[] = transformInfos.flat().map((t) => {
|
||||
const transform = t.transforms?.[0];
|
||||
return { ...transform._meta, transformId: transform?.id };
|
||||
});
|
||||
|
||||
const shouldInstallSequentially =
|
||||
uniqBy(transformsMetadata, 'order').length === transforms.length;
|
||||
|
||||
let authorizedTransforms = [];
|
||||
|
||||
if (shouldInstallSequentially) {
|
||||
const sortedTransformsMetadata = sortBy(transformsMetadata, [
|
||||
(t) => t.package?.name,
|
||||
(t) => t.fleet_transform_version,
|
||||
(t) => t.order,
|
||||
]);
|
||||
|
||||
for (const { transformId, ...meta } of sortedTransformsMetadata) {
|
||||
const authorizedTransform = await reauthorizeAndStartTransform({
|
||||
esClient,
|
||||
logger,
|
||||
transformId,
|
||||
secondaryAuth,
|
||||
meta: { ...meta, last_authorized_by: username },
|
||||
});
|
||||
|
||||
authorizedTransforms.push(authorizedTransform);
|
||||
}
|
||||
} else {
|
||||
// Else, create & start all the transforms at once for speed
|
||||
const transformsPromises = transformsMetadata.map(async ({ transformId, ...meta }) => {
|
||||
return await reauthorizeAndStartTransform({
|
||||
esClient,
|
||||
logger,
|
||||
transformId,
|
||||
secondaryAuth,
|
||||
meta: { ...meta, last_authorized_by: username },
|
||||
});
|
||||
});
|
||||
|
||||
authorizedTransforms = await Promise.all(transformsPromises).then((results) => results.flat());
|
||||
}
|
||||
|
||||
const so = await savedObjectsClient.get<Installation>(PACKAGES_SAVED_OBJECT_TYPE, pkgName);
|
||||
const esReferences = so.attributes.installed_es ?? [];
|
||||
|
||||
const successfullyAuthorizedTransforms = authorizedTransforms.filter((t) => t.success);
|
||||
const authorizedTransformsRefs = successfullyAuthorizedTransforms.map((t) => ({
|
||||
type: ElasticsearchAssetType.transform,
|
||||
id: t.transformId,
|
||||
version: pkgVersion,
|
||||
}));
|
||||
await updateEsAssetReferences(savedObjectsClient, pkgName, esReferences, {
|
||||
assetsToRemove: authorizedTransformsRefs,
|
||||
assetsToAdd: authorizedTransformsRefs,
|
||||
});
|
||||
return authorizedTransforms;
|
||||
}
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
|
||||
import type { SecondaryAuthorizationHeader } from '../../../../../common/types/models/transform_api_key';
|
||||
import { ElasticsearchAssetType } from '../../../../types';
|
||||
import type { EsAssetReference } from '../../../../types';
|
||||
import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common/constants';
|
||||
|
@ -24,7 +25,8 @@ export const stopTransforms = async (transformIds: string[], esClient: Elasticse
|
|||
export const deleteTransforms = async (
|
||||
esClient: ElasticsearchClient,
|
||||
transformIds: string[],
|
||||
deleteDestinationIndices = false
|
||||
deleteDestinationIndices = false,
|
||||
secondaryAuth?: SecondaryAuthorizationHeader
|
||||
) => {
|
||||
const logger = appContextService.getLogger();
|
||||
if (transformIds.length) {
|
||||
|
@ -41,7 +43,7 @@ export const deleteTransforms = async (
|
|||
await stopTransforms([transformId], esClient);
|
||||
await esClient.transform.deleteTransform(
|
||||
{ force: true, transform_id: transformId },
|
||||
{ ignore: [404] }
|
||||
{ ...(secondaryAuth ? secondaryAuth : {}), ignore: [404] }
|
||||
);
|
||||
logger.info(`Deleted: ${transformId}`);
|
||||
if (deleteDestinationIndices && transformResponse?.transforms) {
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { getDestinationIndexAliases } from './transform_utils';
|
||||
|
||||
describe('test transform_utils', () => {
|
||||
describe('getDestinationIndexAliases()', function () {
|
||||
test('return transform alias settings when input is an object', () => {
|
||||
const aliasSettings = {
|
||||
'.alerts-security.host-risk-score-latest.latest': { move_on_creation: true },
|
||||
'.alerts-security.host-risk-score-latest.all': { move_on_creation: false },
|
||||
};
|
||||
expect(getDestinationIndexAliases(aliasSettings)).toStrictEqual([
|
||||
{ alias: '.alerts-security.host-risk-score-latest.latest', move_on_creation: true },
|
||||
{ alias: '.alerts-security.host-risk-score-latest.all', move_on_creation: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test('return transform alias settings when input is an array', () => {
|
||||
const aliasSettings = [
|
||||
'.alerts-security.host-risk-score-latest.latest',
|
||||
'.alerts-security.host-risk-score-latest.all',
|
||||
];
|
||||
expect(getDestinationIndexAliases(aliasSettings)).toStrictEqual([
|
||||
{ alias: '.alerts-security.host-risk-score-latest.latest', move_on_creation: true },
|
||||
{ alias: '.alerts-security.host-risk-score-latest.all', move_on_creation: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test('return transform alias settings when input is a string', () => {
|
||||
expect(
|
||||
getDestinationIndexAliases('.alerts-security.host-risk-score-latest.latest')
|
||||
).toStrictEqual([
|
||||
{ alias: '.alerts-security.host-risk-score-latest.latest', move_on_creation: true },
|
||||
]);
|
||||
|
||||
expect(
|
||||
getDestinationIndexAliases('.alerts-security.host-risk-score-latest.all')
|
||||
).toStrictEqual([
|
||||
{ alias: '.alerts-security.host-risk-score-latest.all', move_on_creation: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test('return empty array when input is invalid', () => {
|
||||
expect(getDestinationIndexAliases(undefined)).toStrictEqual([]);
|
||||
|
||||
expect(getDestinationIndexAliases({})).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 { isPopulatedObject } from '@kbn/ml-is-populated-object';
|
||||
|
||||
interface TransformAliasSetting {
|
||||
alias: string;
|
||||
// When move_on_creation: true, all the other indices are removed from the alias,
|
||||
// ensuring that the alias points at only one index (i.e.: the destination index of the current transform).
|
||||
move_on_creation?: boolean;
|
||||
}
|
||||
|
||||
export const getDestinationIndexAliases = (aliasSettings: unknown): TransformAliasSetting[] => {
|
||||
let aliases: TransformAliasSetting[] = [];
|
||||
|
||||
if (!aliasSettings) return aliases;
|
||||
|
||||
// If in form of
|
||||
if (isPopulatedObject<string, { move_on_creation?: boolean }>(aliasSettings)) {
|
||||
Object.keys(aliasSettings).forEach((alias) => {
|
||||
if (aliasSettings.hasOwnProperty(alias) && typeof alias === 'string') {
|
||||
const moveOnCreation = aliasSettings[alias].move_on_creation === true;
|
||||
aliases.push({ alias, move_on_creation: moveOnCreation });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Array.isArray(aliasSettings)) {
|
||||
aliases = aliasSettings.reduce<TransformAliasSetting[]>((acc, alias) => {
|
||||
if (typeof alias === 'string') {
|
||||
acc.push({ alias, move_on_creation: alias.endsWith('.latest') ? true : false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
if (typeof aliasSettings === 'string') {
|
||||
aliases = [
|
||||
{ alias: aliasSettings, move_on_creation: aliasSettings.endsWith('.latest') ? true : false },
|
||||
];
|
||||
}
|
||||
return aliases;
|
||||
};
|
|
@ -5,9 +5,28 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
|
||||
import { HTTPAuthorizationHeader } from '../../../../../common/http_authorization_header';
|
||||
|
||||
import { getInstallation, getInstallationObject } from '../../packages';
|
||||
import type { Installation, RegistryPackage } from '../../../../types';
|
||||
import { ElasticsearchAssetType } from '../../../../types';
|
||||
import { appContextService } from '../../../app_context';
|
||||
|
||||
import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../constants';
|
||||
|
||||
import { getESAssetMetadata } from '../meta';
|
||||
|
||||
import { createAppContextStartContractMock } from '../../../../mocks';
|
||||
|
||||
import { installTransforms } from './install';
|
||||
import { getAsset } from './common';
|
||||
|
||||
jest.mock('../../packages/get', () => {
|
||||
return { getInstallation: jest.fn(), getInstallationObject: jest.fn() };
|
||||
});
|
||||
|
@ -18,30 +37,16 @@ jest.mock('./common', () => {
|
|||
};
|
||||
});
|
||||
|
||||
import type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
|
||||
import { getInstallation, getInstallationObject } from '../../packages';
|
||||
import type { Installation, RegistryPackage } from '../../../../types';
|
||||
import { ElasticsearchAssetType } from '../../../../types';
|
||||
import { appContextService } from '../../../app_context';
|
||||
|
||||
import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../constants';
|
||||
|
||||
import { getESAssetMetadata } from '../meta';
|
||||
|
||||
import { installTransforms } from './install';
|
||||
import { getAsset } from './common';
|
||||
|
||||
const meta = getESAssetMetadata({ packageName: 'endpoint' });
|
||||
|
||||
describe('test transform install', () => {
|
||||
let esClient: ReturnType<typeof elasticsearchClientMock.createElasticsearchClient>;
|
||||
let savedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
|
||||
|
||||
const authorizationHeader = new HTTPAuthorizationHeader(
|
||||
'Basic',
|
||||
'bW9uaXRvcmluZ191c2VyOm1scWFfYWRtaW4='
|
||||
);
|
||||
const getYamlTestData = (
|
||||
autoStart: boolean | undefined = undefined,
|
||||
transformVersion: string = '0.1.0'
|
||||
|
@ -113,7 +118,8 @@ _meta:
|
|||
body: {
|
||||
description: 'Merges latest endpoint and Agent metadata documents.',
|
||||
dest: {
|
||||
index: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
index: '.metrics-endpoint.metadata_united_default',
|
||||
aliases: [],
|
||||
},
|
||||
frequency: '1s',
|
||||
pivot: {
|
||||
|
@ -145,7 +151,7 @@ _meta:
|
|||
field: 'updated_at',
|
||||
},
|
||||
},
|
||||
_meta: { fleet_transform_version: transformVersion, ...meta },
|
||||
_meta: { fleet_transform_version: transformVersion, ...meta, run_as_kibana_system: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -336,7 +342,7 @@ _meta:
|
|||
'logs-endpoint.metadata_current-template@package',
|
||||
'logs-endpoint.metadata_current-template@custom',
|
||||
],
|
||||
index_patterns: ['.metrics-endpoint.metadata_united_default-0.16.0-dev.0'],
|
||||
index_patterns: ['.metrics-endpoint.metadata_united_default'],
|
||||
priority: 250,
|
||||
template: { mappings: undefined, settings: undefined },
|
||||
},
|
||||
|
@ -346,19 +352,8 @@ _meta:
|
|||
],
|
||||
]);
|
||||
|
||||
// Destination index is created before transform is created
|
||||
expect(esClient.indices.create.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
aliases: {
|
||||
'.metrics-endpoint.metadata_united_default.all': {},
|
||||
'.metrics-endpoint.metadata_united_default.latest': {},
|
||||
},
|
||||
index: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
},
|
||||
{ ignore: [400] },
|
||||
],
|
||||
]);
|
||||
// Destination index is not created before transform is created
|
||||
expect(esClient.indices.create.mock.calls).toEqual([]);
|
||||
|
||||
expect(esClient.transform.putTransform.mock.calls).toEqual([[expectedData.TRANSFORM]]);
|
||||
expect(esClient.transform.startTransform.mock.calls).toEqual([
|
||||
|
@ -382,7 +377,7 @@ _meta:
|
|||
type: ElasticsearchAssetType.ingestPipeline,
|
||||
},
|
||||
{
|
||||
id: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
id: '.metrics-endpoint.metadata_united_default',
|
||||
type: ElasticsearchAssetType.index,
|
||||
},
|
||||
{
|
||||
|
@ -411,6 +406,48 @@ _meta:
|
|||
refresh: false,
|
||||
},
|
||||
],
|
||||
// After transforms are installed, es asset reference needs to be updated if they are deferred or not
|
||||
[
|
||||
'epm-packages',
|
||||
'endpoint',
|
||||
{
|
||||
installed_es: [
|
||||
{
|
||||
id: 'metrics-endpoint.policy-0.16.0-dev.0',
|
||||
type: ElasticsearchAssetType.ingestPipeline,
|
||||
},
|
||||
{
|
||||
id: '.metrics-endpoint.metadata_united_default',
|
||||
type: ElasticsearchAssetType.index,
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template',
|
||||
type: ElasticsearchAssetType.indexTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template@custom',
|
||||
type: ElasticsearchAssetType.componentTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template@package',
|
||||
type: ElasticsearchAssetType.componentTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
// After transforms are installed, es asset reference needs to be updated if they are deferred or not
|
||||
deferred: false,
|
||||
id: 'logs-endpoint.metadata_current-default-0.2.0',
|
||||
type: ElasticsearchAssetType.transform,
|
||||
version: '0.2.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
refresh: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -588,7 +625,7 @@ _meta:
|
|||
'logs-endpoint.metadata_current-template@package',
|
||||
'logs-endpoint.metadata_current-template@custom',
|
||||
],
|
||||
index_patterns: ['.metrics-endpoint.metadata_united_default-0.16.0-dev.0'],
|
||||
index_patterns: ['.metrics-endpoint.metadata_united_default'],
|
||||
priority: 250,
|
||||
template: { mappings: undefined, settings: undefined },
|
||||
},
|
||||
|
@ -598,19 +635,8 @@ _meta:
|
|||
],
|
||||
]);
|
||||
|
||||
// Destination index is created before transform is created
|
||||
expect(esClient.indices.create.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
aliases: {
|
||||
'.metrics-endpoint.metadata_united_default.all': {},
|
||||
'.metrics-endpoint.metadata_united_default.latest': {},
|
||||
},
|
||||
index: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
},
|
||||
{ ignore: [400] },
|
||||
],
|
||||
]);
|
||||
// Destination index is not created before transform is created
|
||||
expect(esClient.indices.create.mock.calls).toEqual([]);
|
||||
|
||||
expect(esClient.transform.putTransform.mock.calls).toEqual([[expectedData.TRANSFORM]]);
|
||||
expect(esClient.transform.startTransform.mock.calls).toEqual([
|
||||
|
@ -634,7 +660,7 @@ _meta:
|
|||
type: ElasticsearchAssetType.ingestPipeline,
|
||||
},
|
||||
{
|
||||
id: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
id: '.metrics-endpoint.metadata_united_default',
|
||||
type: ElasticsearchAssetType.index,
|
||||
},
|
||||
{
|
||||
|
@ -663,6 +689,47 @@ _meta:
|
|||
refresh: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'epm-packages',
|
||||
'endpoint',
|
||||
{
|
||||
installed_es: [
|
||||
{
|
||||
id: 'metrics-endpoint.policy-0.1.0-dev.0',
|
||||
type: ElasticsearchAssetType.ingestPipeline,
|
||||
},
|
||||
{
|
||||
id: '.metrics-endpoint.metadata_united_default',
|
||||
type: ElasticsearchAssetType.index,
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template',
|
||||
type: ElasticsearchAssetType.indexTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template@custom',
|
||||
type: ElasticsearchAssetType.componentTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template@package',
|
||||
type: ElasticsearchAssetType.componentTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
// After transforms are installed, es asset reference needs to be updated if they are deferred or not
|
||||
deferred: false,
|
||||
id: 'logs-endpoint.metadata_current-default-0.2.0',
|
||||
type: ElasticsearchAssetType.transform,
|
||||
version: '0.2.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
refresh: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -809,7 +876,7 @@ _meta:
|
|||
'logs-endpoint.metadata_current-template@package',
|
||||
'logs-endpoint.metadata_current-template@custom',
|
||||
],
|
||||
index_patterns: ['.metrics-endpoint.metadata_united_default-0.16.0-dev.0'],
|
||||
index_patterns: ['.metrics-endpoint.metadata_united_default'],
|
||||
priority: 250,
|
||||
template: { mappings: undefined, settings: undefined },
|
||||
},
|
||||
|
@ -819,19 +886,8 @@ _meta:
|
|||
],
|
||||
]);
|
||||
|
||||
// Destination index is created before transform is created
|
||||
expect(esClient.indices.create.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
aliases: {
|
||||
'.metrics-endpoint.metadata_united_default.all': {},
|
||||
'.metrics-endpoint.metadata_united_default.latest': {},
|
||||
},
|
||||
index: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
},
|
||||
{ ignore: [400] },
|
||||
],
|
||||
]);
|
||||
// Destination index is not created before transform is created
|
||||
expect(esClient.indices.create.mock.calls).toEqual([]);
|
||||
|
||||
expect(esClient.transform.putTransform.mock.calls).toEqual([[expectedData.TRANSFORM]]);
|
||||
expect(esClient.transform.startTransform.mock.calls).toEqual([
|
||||
|
@ -855,7 +911,7 @@ _meta:
|
|||
type: ElasticsearchAssetType.ingestPipeline,
|
||||
},
|
||||
{
|
||||
id: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
id: '.metrics-endpoint.metadata_united_default',
|
||||
type: ElasticsearchAssetType.index,
|
||||
},
|
||||
{
|
||||
|
@ -884,6 +940,46 @@ _meta:
|
|||
refresh: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'epm-packages',
|
||||
'endpoint',
|
||||
{
|
||||
installed_es: [
|
||||
{
|
||||
id: 'metrics-endpoint.policy-0.16.0-dev.0',
|
||||
type: ElasticsearchAssetType.ingestPipeline,
|
||||
},
|
||||
{
|
||||
id: '.metrics-endpoint.metadata_united_default',
|
||||
type: ElasticsearchAssetType.index,
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template',
|
||||
type: ElasticsearchAssetType.indexTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template@custom',
|
||||
type: ElasticsearchAssetType.componentTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
id: 'logs-endpoint.metadata_current-template@package',
|
||||
type: ElasticsearchAssetType.componentTemplate,
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
deferred: false,
|
||||
id: 'logs-endpoint.metadata_current-default-0.2.0',
|
||||
type: ElasticsearchAssetType.transform,
|
||||
version: '0.2.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
refresh: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -931,7 +1027,8 @@ _meta:
|
|||
esClient,
|
||||
savedObjectsClient,
|
||||
loggerMock.create(),
|
||||
previousInstallation.installed_es
|
||||
previousInstallation.installed_es,
|
||||
authorizationHeader
|
||||
);
|
||||
|
||||
expect(esClient.transform.putTransform.mock.calls).toEqual([[expectedData.TRANSFORM]]);
|
||||
|
@ -1026,43 +1123,11 @@ _meta:
|
|||
previousInstallation.installed_es
|
||||
);
|
||||
|
||||
expect(esClient.indices.create.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
index: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
aliases: {
|
||||
'.metrics-endpoint.metadata_united_default.all': {},
|
||||
'.metrics-endpoint.metadata_united_default.latest': {},
|
||||
},
|
||||
},
|
||||
{ ignore: [400] },
|
||||
],
|
||||
]);
|
||||
expect(esClient.indices.create.mock.calls).toEqual([]);
|
||||
|
||||
// If downgrading to and older version, and destination index already exists
|
||||
// aliases should still be updated to point .latest to this index
|
||||
expect(esClient.indices.updateAliases.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
body: {
|
||||
actions: [
|
||||
{
|
||||
add: {
|
||||
index: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
alias: '.metrics-endpoint.metadata_united_default.all',
|
||||
},
|
||||
},
|
||||
{
|
||||
add: {
|
||||
index: '.metrics-endpoint.metadata_united_default-0.16.0-dev.0',
|
||||
alias: '.metrics-endpoint.metadata_united_default.latest',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(esClient.indices.updateAliases.mock.calls).toEqual([]);
|
||||
|
||||
expect(esClient.transform.deleteTransform.mock.calls).toEqual([
|
||||
[
|
||||
|
|
|
@ -131,7 +131,17 @@ function getTest(
|
|||
method: mocks.packageClient.reinstallEsAssets.bind(mocks.packageClient),
|
||||
args: [pkg, paths],
|
||||
spy: jest.spyOn(epmTransformsInstall, 'installTransforms'),
|
||||
spyArgs: [pkg, paths, mocks.esClient, mocks.soClient, mocks.logger],
|
||||
spyArgs: [
|
||||
pkg,
|
||||
paths,
|
||||
mocks.esClient,
|
||||
mocks.soClient,
|
||||
mocks.logger,
|
||||
// Undefined es references
|
||||
undefined,
|
||||
// Undefined secondary authorization
|
||||
undefined,
|
||||
],
|
||||
spyResponse: {
|
||||
installedTransforms: [
|
||||
{
|
||||
|
|
|
@ -14,6 +14,8 @@ import type {
|
|||
Logger,
|
||||
} from '@kbn/core/server';
|
||||
|
||||
import { HTTPAuthorizationHeader } from '../../../common/http_authorization_header';
|
||||
|
||||
import type { PackageList } from '../../../common';
|
||||
|
||||
import type {
|
||||
|
@ -96,7 +98,8 @@ export class PackageServiceImpl implements PackageService {
|
|||
this.internalEsClient,
|
||||
this.internalSoClient,
|
||||
this.logger,
|
||||
preflightCheck
|
||||
preflightCheck,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -106,13 +109,23 @@ export class PackageServiceImpl implements PackageService {
|
|||
}
|
||||
|
||||
class PackageClientImpl implements PackageClient {
|
||||
private authorizationHeader?: HTTPAuthorizationHeader | null = undefined;
|
||||
|
||||
constructor(
|
||||
private readonly internalEsClient: ElasticsearchClient,
|
||||
private readonly internalSoClient: SavedObjectsClientContract,
|
||||
private readonly logger: Logger,
|
||||
private readonly preflightCheck?: () => void | Promise<void>
|
||||
private readonly preflightCheck?: () => void | Promise<void>,
|
||||
private readonly request?: KibanaRequest
|
||||
) {}
|
||||
|
||||
private getAuthorizationHeader() {
|
||||
if (this.request) {
|
||||
this.authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(this.request);
|
||||
return this.authorizationHeader;
|
||||
}
|
||||
}
|
||||
|
||||
public async getInstallation(pkgName: string) {
|
||||
await this.#runPreflight();
|
||||
return getInstallation({
|
||||
|
@ -127,6 +140,7 @@ class PackageClientImpl implements PackageClient {
|
|||
spaceId?: string;
|
||||
}): Promise<Installation | undefined> {
|
||||
await this.#runPreflight();
|
||||
|
||||
return ensureInstalledPackage({
|
||||
...options,
|
||||
esClient: this.internalEsClient,
|
||||
|
@ -193,12 +207,16 @@ class PackageClientImpl implements PackageClient {
|
|||
}
|
||||
|
||||
async #reinstallTransforms(packageInfo: InstallablePackage, paths: string[]) {
|
||||
const authorizationHeader = await this.getAuthorizationHeader();
|
||||
|
||||
const { installedTransforms } = await installTransforms(
|
||||
packageInfo,
|
||||
paths,
|
||||
this.internalEsClient,
|
||||
this.internalSoClient,
|
||||
this.logger
|
||||
this.logger,
|
||||
undefined,
|
||||
authorizationHeader
|
||||
);
|
||||
return installedTransforms;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server';
|
|||
|
||||
import type { IAssignmentService, ITagsClient } from '@kbn/saved-objects-tagging-plugin/server';
|
||||
|
||||
import type { HTTPAuthorizationHeader } from '../../../../common/http_authorization_header';
|
||||
|
||||
import { getNormalizedDataStreams } from '../../../../common/services';
|
||||
|
||||
import {
|
||||
|
@ -74,6 +76,7 @@ export async function _installPackage({
|
|||
installSource,
|
||||
spaceId,
|
||||
verificationResult,
|
||||
authorizationHeader,
|
||||
}: {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
savedObjectsImporter: Pick<ISavedObjectsImporter, 'import' | 'resolveImportErrors'>;
|
||||
|
@ -88,6 +91,7 @@ export async function _installPackage({
|
|||
installSource: InstallSource;
|
||||
spaceId: string;
|
||||
verificationResult?: PackageVerificationResult;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}): Promise<AssetReference[]> {
|
||||
const { name: pkgName, version: pkgVersion, title: pkgTitle } = packageInfo;
|
||||
|
||||
|
@ -245,7 +249,15 @@ export async function _installPackage({
|
|||
);
|
||||
|
||||
({ esReferences } = await withPackageSpan('Install transforms', () =>
|
||||
installTransforms(packageInfo, paths, esClient, savedObjectsClient, logger, esReferences)
|
||||
installTransforms(
|
||||
packageInfo,
|
||||
paths,
|
||||
esClient,
|
||||
savedObjectsClient,
|
||||
logger,
|
||||
esReferences,
|
||||
authorizationHeader
|
||||
)
|
||||
));
|
||||
|
||||
// If this is an update or retrying an update, delete the previous version's pipelines
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
|
||||
import type { HTTPAuthorizationHeader } from '../../../../common/http_authorization_header';
|
||||
|
||||
import { appContextService } from '../../app_context';
|
||||
import * as Registry from '../registry';
|
||||
|
||||
|
@ -23,6 +25,7 @@ interface BulkInstallPackagesParams {
|
|||
spaceId: string;
|
||||
preferredSource?: 'registry' | 'bundled';
|
||||
prerelease?: boolean;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}
|
||||
|
||||
export async function bulkInstallPackages({
|
||||
|
@ -32,6 +35,7 @@ export async function bulkInstallPackages({
|
|||
spaceId,
|
||||
force,
|
||||
prerelease,
|
||||
authorizationHeader,
|
||||
}: BulkInstallPackagesParams): Promise<BulkInstallResponse[]> {
|
||||
const logger = appContextService.getLogger();
|
||||
|
||||
|
@ -94,6 +98,7 @@ export async function bulkInstallPackages({
|
|||
spaceId,
|
||||
force,
|
||||
prerelease,
|
||||
authorizationHeader,
|
||||
});
|
||||
|
||||
if (installResult.error) {
|
||||
|
|
|
@ -25,6 +25,8 @@ import { uniqBy } from 'lodash';
|
|||
|
||||
import type { LicenseType } from '@kbn/licensing-plugin/server';
|
||||
|
||||
import type { HTTPAuthorizationHeader } from '../../../../common/http_authorization_header';
|
||||
|
||||
import { isPackagePrerelease, getNormalizedDataStreams } from '../../../../common/services';
|
||||
|
||||
import { FLEET_INSTALL_FORMAT_VERSION } from '../../../constants/fleet_es_assets';
|
||||
|
@ -120,6 +122,7 @@ export async function ensureInstalledPackage(options: {
|
|||
pkgVersion?: string;
|
||||
spaceId?: string;
|
||||
force?: boolean;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}): Promise<Installation> {
|
||||
const {
|
||||
savedObjectsClient,
|
||||
|
@ -128,6 +131,7 @@ export async function ensureInstalledPackage(options: {
|
|||
pkgVersion,
|
||||
force = false,
|
||||
spaceId = DEFAULT_SPACE_ID,
|
||||
authorizationHeader,
|
||||
} = options;
|
||||
|
||||
// If pkgVersion isn't specified, find the latest package version
|
||||
|
@ -152,6 +156,7 @@ export async function ensureInstalledPackage(options: {
|
|||
esClient,
|
||||
neverIgnoreVerificationError: !force,
|
||||
force: true, // Always force outdated packages to be installed if a later version isn't installed
|
||||
authorizationHeader,
|
||||
});
|
||||
|
||||
if (installResult.error) {
|
||||
|
@ -188,6 +193,7 @@ export async function handleInstallPackageFailure({
|
|||
installedPkg,
|
||||
esClient,
|
||||
spaceId,
|
||||
authorizationHeader,
|
||||
}: {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
error: FleetError | Boom.Boom | Error;
|
||||
|
@ -196,6 +202,7 @@ export async function handleInstallPackageFailure({
|
|||
installedPkg: SavedObject<Installation> | undefined;
|
||||
esClient: ElasticsearchClient;
|
||||
spaceId: string;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}) {
|
||||
if (error instanceof FleetError) {
|
||||
return;
|
||||
|
@ -232,6 +239,7 @@ export async function handleInstallPackageFailure({
|
|||
esClient,
|
||||
spaceId,
|
||||
force: true,
|
||||
authorizationHeader,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -255,6 +263,7 @@ interface InstallRegistryPackageParams {
|
|||
neverIgnoreVerificationError?: boolean;
|
||||
ignoreConstraints?: boolean;
|
||||
prerelease?: boolean;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}
|
||||
interface InstallUploadedArchiveParams {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
|
@ -263,6 +272,7 @@ interface InstallUploadedArchiveParams {
|
|||
contentType: string;
|
||||
spaceId: string;
|
||||
version?: string;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}
|
||||
|
||||
function getTelemetryEvent(pkgName: string, pkgVersion: string): PackageUpdateEvent {
|
||||
|
@ -290,6 +300,7 @@ async function installPackageFromRegistry({
|
|||
pkgkey,
|
||||
esClient,
|
||||
spaceId,
|
||||
authorizationHeader,
|
||||
force = false,
|
||||
ignoreConstraints = false,
|
||||
neverIgnoreVerificationError = false,
|
||||
|
@ -366,6 +377,7 @@ async function installPackageFromRegistry({
|
|||
packageInfo,
|
||||
paths,
|
||||
verificationResult,
|
||||
authorizationHeader,
|
||||
});
|
||||
} catch (e) {
|
||||
sendEvent({
|
||||
|
@ -400,6 +412,7 @@ async function installPackageCommon(options: {
|
|||
paths: string[];
|
||||
verificationResult?: PackageVerificationResult;
|
||||
telemetryEvent?: PackageUpdateEvent;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}): Promise<InstallResult> {
|
||||
const {
|
||||
pkgName,
|
||||
|
@ -414,6 +427,7 @@ async function installPackageCommon(options: {
|
|||
packageInfo,
|
||||
paths,
|
||||
verificationResult,
|
||||
authorizationHeader,
|
||||
} = options;
|
||||
let { telemetryEvent } = options;
|
||||
const logger = appContextService.getLogger();
|
||||
|
@ -496,6 +510,7 @@ async function installPackageCommon(options: {
|
|||
spaceId,
|
||||
verificationResult,
|
||||
installSource,
|
||||
authorizationHeader,
|
||||
})
|
||||
.then(async (assets) => {
|
||||
await removeOldAssets({
|
||||
|
@ -519,6 +534,7 @@ async function installPackageCommon(options: {
|
|||
installedPkg,
|
||||
spaceId,
|
||||
esClient,
|
||||
authorizationHeader,
|
||||
});
|
||||
sendEvent({
|
||||
...telemetryEvent!,
|
||||
|
@ -548,6 +564,7 @@ async function installPackageByUpload({
|
|||
contentType,
|
||||
spaceId,
|
||||
version,
|
||||
authorizationHeader,
|
||||
}: InstallUploadedArchiveParams): Promise<InstallResult> {
|
||||
// if an error happens during getInstallType, report that we don't know
|
||||
let installType: InstallType = 'unknown';
|
||||
|
@ -595,6 +612,7 @@ async function installPackageByUpload({
|
|||
force: true, // upload has implicit force
|
||||
packageInfo,
|
||||
paths,
|
||||
authorizationHeader,
|
||||
});
|
||||
} catch (e) {
|
||||
return {
|
||||
|
@ -622,6 +640,8 @@ export async function installPackage(args: InstallPackageParams): Promise<Instal
|
|||
const logger = appContextService.getLogger();
|
||||
const { savedObjectsClient, esClient } = args;
|
||||
|
||||
const authorizationHeader = args.authorizationHeader;
|
||||
|
||||
const bundledPackages = await getBundledPackages();
|
||||
|
||||
if (args.installSource === 'registry') {
|
||||
|
@ -644,6 +664,7 @@ export async function installPackage(args: InstallPackageParams): Promise<Instal
|
|||
contentType: 'application/zip',
|
||||
spaceId,
|
||||
version: matchingBundledPackage.version,
|
||||
authorizationHeader,
|
||||
});
|
||||
|
||||
return { ...response, installSource: 'bundled' };
|
||||
|
@ -659,6 +680,7 @@ export async function installPackage(args: InstallPackageParams): Promise<Instal
|
|||
neverIgnoreVerificationError,
|
||||
ignoreConstraints,
|
||||
prerelease,
|
||||
authorizationHeader,
|
||||
});
|
||||
return response;
|
||||
} else if (args.installSource === 'upload') {
|
||||
|
@ -669,6 +691,7 @@ export async function installPackage(args: InstallPackageParams): Promise<Instal
|
|||
archiveBuffer,
|
||||
contentType,
|
||||
spaceId,
|
||||
authorizationHeader,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
|
|
@ -23,10 +23,12 @@ import { safeLoad } from 'js-yaml';
|
|||
|
||||
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants';
|
||||
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/server';
|
||||
import { type AuthenticatedUser } from '@kbn/security-plugin/server';
|
||||
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { HTTPAuthorizationHeader } from '../../common/http_authorization_header';
|
||||
|
||||
import {
|
||||
packageToPackagePolicy,
|
||||
packageToPackagePolicyInputs,
|
||||
|
@ -128,6 +130,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
|
|||
esClient: ElasticsearchClient,
|
||||
packagePolicy: NewPackagePolicy,
|
||||
options: {
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
spaceId?: string;
|
||||
id?: string;
|
||||
user?: AuthenticatedUser;
|
||||
|
@ -146,6 +149,12 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
|
|||
options.id = SavedObjectsUtils.generateId();
|
||||
}
|
||||
|
||||
let authorizationHeader = options.authorizationHeader;
|
||||
|
||||
if (!authorizationHeader && request) {
|
||||
authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request);
|
||||
}
|
||||
|
||||
auditLoggingService.writeCustomSoAuditLog({
|
||||
action: 'create',
|
||||
id: options.id,
|
||||
|
@ -200,6 +209,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
|
|||
pkgName: enrichedPackagePolicy.package.name,
|
||||
pkgVersion: enrichedPackagePolicy.package.version,
|
||||
force: options?.force,
|
||||
authorizationHeader,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1606,6 +1616,7 @@ class PackagePolicyClientWithAuthz extends PackagePolicyClientImpl {
|
|||
esClient: ElasticsearchClient,
|
||||
packagePolicy: NewPackagePolicy,
|
||||
options?: {
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
spaceId?: string;
|
||||
id?: string;
|
||||
user?: AuthenticatedUser;
|
||||
|
|
|
@ -9,6 +9,8 @@ import type { KibanaRequest, Logger, RequestHandlerContext } from '@kbn/core/ser
|
|||
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/server';
|
||||
|
||||
import type { HTTPAuthorizationHeader } from '../../common/http_authorization_header';
|
||||
|
||||
import type {
|
||||
PostDeletePackagePoliciesResponse,
|
||||
UpgradePackagePolicyResponse,
|
||||
|
@ -40,6 +42,7 @@ export interface PackagePolicyClient {
|
|||
spaceId?: string;
|
||||
id?: string;
|
||||
user?: AuthenticatedUser;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
bumpRevision?: boolean;
|
||||
force?: boolean;
|
||||
skipEnsureInstalled?: boolean;
|
||||
|
@ -59,6 +62,7 @@ export interface PackagePolicyClient {
|
|||
user?: AuthenticatedUser;
|
||||
bumpRevision?: boolean;
|
||||
force?: true;
|
||||
authorizationHeader?: HTTPAuthorizationHeader | null;
|
||||
}
|
||||
): Promise<PackagePolicy[]>;
|
||||
|
||||
|
|
|
@ -80,7 +80,6 @@ export async function ensurePreconfiguredPackagesAndPolicies(
|
|||
const packagesToInstall = packages.map((pkg) =>
|
||||
pkg.version === PRECONFIGURATION_LATEST_KEYWORD ? pkg.name : pkg
|
||||
);
|
||||
|
||||
// Preinstall packages specified in Kibana config
|
||||
const preconfiguredPackages = await bulkInstallPackages({
|
||||
savedObjectsClient: soClient,
|
||||
|
|
|
@ -9,6 +9,8 @@ import { pick } from 'lodash';
|
|||
|
||||
import type { KibanaRequest } from '@kbn/core/server';
|
||||
|
||||
import { TRANSFORM_PLUGIN_ID } from '../../../common/constants/plugin';
|
||||
|
||||
import type { FleetAuthz } from '../../../common';
|
||||
import { INTEGRATIONS_PLUGIN_ID } from '../../../common';
|
||||
import {
|
||||
|
@ -36,6 +38,7 @@ export function checkSuperuser(req: KibanaRequest) {
|
|||
|
||||
const security = appContextService.getSecurity();
|
||||
const user = security.authc.getCurrentUser(req);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
@ -79,6 +82,10 @@ export async function getAuthzFromRequest(req: KibanaRequest): Promise<FleetAuth
|
|||
security.authz.actions.api.get(`${PLUGIN_ID}-setup`),
|
||||
security.authz.actions.api.get(`${INTEGRATIONS_PLUGIN_ID}-all`),
|
||||
security.authz.actions.api.get(`${INTEGRATIONS_PLUGIN_ID}-read`),
|
||||
security.authz.actions.api.get(`${TRANSFORM_PLUGIN_ID}-all`),
|
||||
security.authz.actions.api.get(`${TRANSFORM_PLUGIN_ID}-admin`),
|
||||
security.authz.actions.api.get(`${TRANSFORM_PLUGIN_ID}-read`),
|
||||
|
||||
...endpointPrivileges,
|
||||
],
|
||||
});
|
||||
|
@ -93,7 +100,7 @@ export async function getAuthzFromRequest(req: KibanaRequest): Promise<FleetAuth
|
|||
);
|
||||
const fleetSetupAuth = getAuthorizationFromPrivileges(privileges.kibana, 'fleet-setup');
|
||||
|
||||
return {
|
||||
const authz = {
|
||||
...calculateAuthz({
|
||||
fleet: { all: fleetAllAuth, setup: fleetSetupAuth },
|
||||
integrations: {
|
||||
|
@ -104,6 +111,8 @@ export async function getAuthzFromRequest(req: KibanaRequest): Promise<FleetAuth
|
|||
}),
|
||||
packagePrivileges: calculatePackagePrivilegesFromKibanaPrivileges(privileges.kibana),
|
||||
};
|
||||
|
||||
return authz;
|
||||
}
|
||||
|
||||
return calculateAuthz({
|
||||
|
|
|
@ -102,6 +102,19 @@ export const InstallPackageFromRegistryRequestSchema = {
|
|||
),
|
||||
};
|
||||
|
||||
export const ReauthorizeTransformRequestSchema = {
|
||||
params: schema.object({
|
||||
pkgName: schema.string(),
|
||||
pkgVersion: schema.maybe(schema.string()),
|
||||
}),
|
||||
query: schema.object({
|
||||
prerelease: schema.maybe(schema.boolean()),
|
||||
}),
|
||||
body: schema.object({
|
||||
transforms: schema.arrayOf(schema.object({ transformId: schema.string() })),
|
||||
}),
|
||||
};
|
||||
|
||||
export const InstallPackageFromRegistryRequestSchemaDeprecated = {
|
||||
params: schema.object({
|
||||
pkgkey: schema.string(),
|
||||
|
|
|
@ -95,5 +95,6 @@
|
|||
"@kbn/core-http-request-handler-context-server",
|
||||
"@kbn/shared-ux-router",
|
||||
"@kbn/shared-ux-link-redirect-app",
|
||||
"@kbn/core-http-router-server-internal",
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue