[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&mdash;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&mdash;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:
Quynh Nguyen (Quinn) 2023-04-20 13:07:42 -05:00 committed by GitHub
parent a33b6d0731
commit 1fe26f3fba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1942 additions and 295 deletions

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View 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;
}
}

View file

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

View file

@ -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 = {

View file

@ -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'> & {

View file

@ -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[] };
}

View file

@ -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')!

View file

@ -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" />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
/>
&nbsp;
{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,

View file

@ -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"

View file

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

View file

@ -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>
</>

View file

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

View file

@ -32,7 +32,7 @@ export const getHrefToObjectInKibanaApp = ({
id,
http,
}: {
type: KibanaAssetType;
type: KibanaAssetType | undefined;
id: string;
http: HttpStart;
}): undefined | string => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -243,6 +243,7 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({
id: { type: 'keyword' },
type: { type: 'keyword' },
version: { type: 'keyword' },
deferred: { type: 'boolean' },
},
},
installed_kibana: {

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = (

View file

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

View file

@ -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) {

View file

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

View file

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

View file

@ -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([
[

View file

@ -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: [
{

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

@ -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[]>;

View file

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

View file

@ -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({

View file

@ -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(),

View file

@ -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",
]
}