Allow users with read access to view Integrations app (#113925)

This commit is contained in:
Josh Dover 2021-10-13 10:20:20 -06:00 committed by GitHub
parent 9f07f71500
commit 3ab04b67f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 371 additions and 198 deletions

View file

@ -7,12 +7,10 @@
import React, { memo, useEffect, useState } from 'react';
import type { AppMountParameters } from 'kibana/public';
import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel, EuiPortal } from '@elastic/eui';
import { EuiErrorBoundary, EuiPortal } from '@elastic/eui';
import type { History } from 'history';
import { Router, Redirect, Route, Switch } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import useObservable from 'react-use/lib/useObservable';
import {
@ -49,29 +47,23 @@ const ErrorLayout = ({ children }: { children: JSX.Element }) => (
</EuiErrorBoundary>
);
const Panel = styled(EuiPanel)`
max-width: 500px;
margin-right: auto;
margin-left: auto;
`;
export const WithPermissionsAndSetup: React.FC = memo(({ children }) => {
useBreadcrumbs('integrations');
const [isPermissionsLoading, setIsPermissionsLoading] = useState<boolean>(false);
const [permissionsError, setPermissionsError] = useState<string>();
const [isInitialized, setIsInitialized] = useState(false);
const [initializationError, setInitializationError] = useState<Error | null>(null);
useEffect(() => {
(async () => {
setPermissionsError(undefined);
setIsInitialized(false);
setInitializationError(null);
try {
// Attempt Fleet Setup if user has permissions, otherwise skip
setIsPermissionsLoading(true);
const permissionsResponse = await sendGetPermissionsCheck();
setIsPermissionsLoading(false);
if (permissionsResponse.data?.success) {
try {
const setupResponse = await sendSetup();
@ -83,69 +75,20 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => {
}
setIsInitialized(true);
} else {
setPermissionsError(permissionsResponse.data?.error || 'REQUEST_ERROR');
setIsInitialized(true);
}
} catch (err) {
setPermissionsError('REQUEST_ERROR');
} catch {
// If there's an error checking permissions, default to proceeding without running setup
// User will only have access to EPM endpoints if they actually have permission
setIsInitialized(true);
}
})();
}, []);
if (isPermissionsLoading || permissionsError) {
if (isPermissionsLoading) {
return (
<ErrorLayout>
{isPermissionsLoading ? (
<Loading />
) : permissionsError === 'REQUEST_ERROR' ? (
<Error
title={
<FormattedMessage
id="xpack.fleet.permissionsRequestErrorMessageTitle"
defaultMessage="Unable to check permissions"
/>
}
error={i18n.translate('xpack.fleet.permissionsRequestErrorMessageDescription', {
defaultMessage: 'There was a problem checking Fleet permissions',
})}
/>
) : (
<Panel>
<EuiEmptyPrompt
iconType="securityApp"
title={
<h2>
{permissionsError === 'MISSING_SUPERUSER_ROLE' ? (
<FormattedMessage
id="xpack.fleet.permissionDeniedErrorTitle"
defaultMessage="Permission denied"
/>
) : (
<FormattedMessage
id="xpack.fleet.securityRequiredErrorTitle"
defaultMessage="Security is not enabled"
/>
)}
</h2>
}
body={
<p>
{permissionsError === 'MISSING_SUPERUSER_ROLE' ? (
<FormattedMessage
id="xpack.fleet.integrationsPermissionDeniedErrorMessage"
defaultMessage="You are not authorized to access Integrations. Integrations requires {roleName} privileges."
values={{ roleName: <EuiCode>superuser</EuiCode> }}
/>
) : (
<FormattedMessage
id="xpack.fleet.integrationsSecurityRequiredErrorMessage"
defaultMessage="You must enable security in Kibana and Elasticsearch to use Integrations."
/>
)}
</p>
}
/>
</Panel>
)}
<Loading />
</ErrorLayout>
);
}

View file

@ -9,6 +9,7 @@ import React, { useEffect, useState } from 'react';
import { Redirect } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer } from '@elastic/eui';
import { groupBy } from 'lodash';
import { Loading, Error, ExtensionWrapper } from '../../../../../components';
@ -67,8 +68,26 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
id,
type,
}));
const { savedObjects } = await savedObjectsClient.bulkGet(objectsToGet);
setAssetsSavedObjects(savedObjects as AssetSavedObject[]);
// 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(
Object.entries(groupBy(objectsToGet, 'type')).map(([type, objects]) =>
savedObjectsClient
.bulkGet(objects)
// Ignore privilege errors
.catch((e: any) => {
if (e?.body?.statusCode === 403) {
return { savedObjects: [] };
} else {
throw e;
}
})
.then(({ savedObjects }) => savedObjects as AssetSavedObject[])
)
);
setAssetsSavedObjects(objectsByType.flat());
} catch (e) {
setFetchError(e);
} finally {

View file

@ -11,6 +11,7 @@ import { act, cleanup } from '@testing-library/react';
import { INTEGRATIONS_ROUTING_PATHS, pagePathGetters } from '../../../../constants';
import type {
CheckPermissionsResponse,
GetAgentPoliciesResponse,
GetFleetStatusResponse,
GetInfoResponse,
@ -23,6 +24,7 @@ import type {
} from '../../../../../../../common/types/models';
import {
agentPolicyRouteService,
appRoutesService,
epmRouteService,
fleetSetupRouteService,
packagePolicyRouteService,
@ -260,6 +262,7 @@ interface EpmPackageDetailsResponseProvidersMock {
fleetSetup: jest.MockedFunction<() => GetFleetStatusResponse>;
packagePolicyList: jest.MockedFunction<() => GetPackagePoliciesResponse>;
agentPolicyList: jest.MockedFunction<() => GetAgentPoliciesResponse>;
appCheckPermissions: jest.MockedFunction<() => CheckPermissionsResponse>;
}
const mockApiCalls = (
@ -740,6 +743,10 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos
},
};
const appCheckPermissionsResponse: CheckPermissionsResponse = {
success: true,
};
const mockedApiInterface: MockedApi<EpmPackageDetailsResponseProvidersMock> = {
waitForApi() {
return new Promise((resolve) => {
@ -757,6 +764,7 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos
fleetSetup: jest.fn().mockReturnValue(agentsSetupResponse),
packagePolicyList: jest.fn().mockReturnValue(packagePoliciesResponse),
agentPolicyList: jest.fn().mockReturnValue(agentPoliciesResponse),
appCheckPermissions: jest.fn().mockReturnValue(appCheckPermissionsResponse),
},
};
@ -792,6 +800,11 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos
return mockedApiInterface.responseProvider.epmGetStats();
}
if (path === appRoutesService.getCheckPermissionsPath()) {
markApiCallAsHandled();
return mockedApiInterface.responseProvider.appCheckPermissions();
}
const err = new Error(`API [GET ${path}] is not MOCKED!`);
// eslint-disable-next-line no-console
console.error(err);

View file

@ -8,10 +8,12 @@ import type { ReactEventHandler } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Redirect, Route, Switch, useLocation, useParams, useHistory } from 'react-router-dom';
import styled from 'styled-components';
import type { EuiToolTipProps } from '@elastic/eui';
import {
EuiBetaBadge,
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
@ -19,6 +21,7 @@ import {
EuiFlexItem,
EuiSpacer,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
@ -30,6 +33,7 @@ import {
useUIExtension,
useBreadcrumbs,
useStartServices,
usePermissionCheck,
} from '../../../../hooks';
import {
PLUGIN_ID,
@ -61,6 +65,7 @@ import { OverviewPage } from './overview';
import { PackagePoliciesPage } from './policies';
import { SettingsPage } from './settings';
import { CustomViewPage } from './custom';
import './index.scss';
export interface DetailParams {
@ -95,7 +100,11 @@ export function Detail() {
const { getId: getAgentPolicyId } = useAgentPolicyContext();
const { pkgkey, panel } = useParams<DetailParams>();
const { getHref } = useLink();
const hasWriteCapabilites = useCapabilities().write;
const hasWriteCapabilities = useCapabilities().write;
const permissionCheck = usePermissionCheck();
const missingSecurityConfiguration =
!permissionCheck.data?.success && permissionCheck.data?.error === 'MISSING_SECURITY';
const userCanInstallIntegrations = hasWriteCapabilities && permissionCheck.data?.success;
const history = useHistory();
const { pathname, search, hash } = useLocation();
const queryParams = useMemo(() => new URLSearchParams(search), [search]);
@ -127,9 +136,11 @@ export function Detail() {
const {
data: packageInfoData,
error: packageInfoError,
isLoading,
isLoading: packageInfoLoading,
} = useGetPackageInfoByKey(pkgkey);
const isLoading = packageInfoLoading || permissionCheck.isLoading;
const showCustomTab =
useUIExtension(packageInfoData?.response.name ?? '', 'package-detail-custom') !== undefined;
@ -327,10 +338,9 @@ export function Detail() {
{ isDivider: true },
{
content: (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiButton
<EuiButtonWithTooltip
fill
isDisabled={!hasWriteCapabilites}
isDisabled={!userCanInstallIntegrations}
iconType="plusInCircle"
href={getHref('add_integration_to_policy', {
pkgkey,
@ -341,6 +351,23 @@ export function Detail() {
})}
onClick={handleAddIntegrationPolicyClick}
data-test-subj="addIntegrationPolicyButton"
tooltip={
!userCanInstallIntegrations
? {
content: missingSecurityConfiguration ? (
<FormattedMessage
id="xpack.fleet.epm.addPackagePolicyButtonSecurityRequiredTooltip"
defaultMessage="To add Elastic Agent Integrations, you must have security enabled and have the minimum required privileges. Contact your administrator."
/>
) : (
<FormattedMessage
id="xpack.fleet.epm.addPackagePolicyButtonPrivilegesRequiredTooltip"
defaultMessage="To add Elastic Agent integrations, you must have the minimum required privileges. Contact your adminstrator."
/>
),
}
: undefined
}
>
<FormattedMessage
id="xpack.fleet.epm.addPackagePolicyButtonText"
@ -349,7 +376,7 @@ export function Detail() {
packageName: integrationInfo?.title || packageInfo.title,
}}
/>
</EuiButton>
</EuiButtonWithTooltip>
),
},
].map((item, index) => (
@ -370,16 +397,17 @@ export function Detail() {
</>
) : undefined,
[
getHref,
handleAddIntegrationPolicyClick,
hasWriteCapabilites,
integration,
integrationInfo,
packageInfo,
packageInstallStatus,
pkgkey,
updateAvailable,
packageInstallStatus,
userCanInstallIntegrations,
getHref,
pkgkey,
integration,
agentPolicyIdFromContext,
handleAddIntegrationPolicyClick,
missingSecurityConfiguration,
integrationInfo?.title,
]
);
@ -407,7 +435,7 @@ export function Detail() {
},
];
if (packageInstallStatus === InstallStatus.installed) {
if (userCanInstallIntegrations && packageInstallStatus === InstallStatus.installed) {
tabs.push({
id: 'policies',
name: (
@ -443,21 +471,23 @@ export function Detail() {
});
}
tabs.push({
id: 'settings',
name: (
<FormattedMessage
id="xpack.fleet.epm.packageDetailsNav.settingsLinkText"
defaultMessage="Settings"
/>
),
isSelected: panel === 'settings',
'data-test-subj': `tab-settings`,
href: getHref('integration_details_settings', {
pkgkey: packageInfoKey,
...(integration ? { integration } : {}),
}),
});
if (userCanInstallIntegrations) {
tabs.push({
id: 'settings',
name: (
<FormattedMessage
id="xpack.fleet.epm.packageDetailsNav.settingsLinkText"
defaultMessage="Settings"
/>
),
isSelected: panel === 'settings',
'data-test-subj': `tab-settings`,
href: getHref('integration_details_settings', {
pkgkey: packageInfoKey,
...(integration ? { integration } : {}),
}),
});
}
if (showCustomTab) {
tabs.push({
@ -478,13 +508,55 @@ export function Detail() {
}
return tabs;
}, [packageInfo, panel, getHref, integration, packageInstallStatus, showCustomTab, CustomAssets]);
}, [
packageInfo,
panel,
getHref,
integration,
userCanInstallIntegrations,
packageInstallStatus,
CustomAssets,
showCustomTab,
]);
const securityCallout = missingSecurityConfiguration ? (
<>
<EuiCallOut
color="warning"
iconType="lock"
title={
<FormattedMessage
id="xpack.fleet.epm.packageDetailsSecurityRequiredCalloutTitle"
defaultMessage="Security needs to be enabled in order to add Elastic Agent integrations"
/>
}
>
<FormattedMessage
id="xpack.fleet.epm.packageDetailsSecurityRequiredCalloutDescription"
defaultMessage="In order to fully use Fleet, you must enable Elasticsearch and Kibana security features.
Follow the {guideLink} to enable security."
values={{
guideLink: (
<a href={services.http.basePath.prepend('/app/fleet')}>
<FormattedMessage
id="xpack.fleet.epm.packageDetailsSecurityRequiredCalloutDescriptionGuideLink"
defaultMessage="steps in this guide"
/>
</a>
),
}}
/>
</EuiCallOut>
<EuiSpacer />
</>
) : undefined;
return (
<WithHeaderLayout
leftColumn={headerLeftContent}
rightColumn={headerRightContent}
rightColumnGrow={false}
topContent={securityCallout}
tabs={headerTabs}
tabsClassName="fleet__epm__shiftNavTabs"
>
@ -526,3 +598,16 @@ export function Detail() {
</WithHeaderLayout>
);
}
type EuiButtonPropsFull = Parameters<typeof EuiButton>[0];
const EuiButtonWithTooltip: React.FC<EuiButtonPropsFull & { tooltip?: Partial<EuiToolTipProps> }> =
({ tooltip: tooltipProps, ...buttonProps }) => {
return tooltipProps ? (
<EuiToolTip {...tooltipProps}>
<EuiButton {...buttonProps} />
</EuiToolTip>
) : (
<EuiButton {...buttonProps} />
);
};

View file

@ -43,6 +43,7 @@ export interface HeaderProps {
leftColumn?: JSX.Element;
rightColumn?: JSX.Element;
rightColumnGrow?: EuiFlexItemProps['grow'];
topContent?: JSX.Element;
tabs?: Array<Omit<EuiTabProps, 'name'> & { name?: JSX.Element | string }>;
tabsClassName?: string;
'data-test-subj'?: string;
@ -61,6 +62,7 @@ export const Header: React.FC<HeaderProps> = ({
leftColumn,
rightColumn,
rightColumnGrow,
topContent,
tabs,
maxWidth,
tabsClassName,
@ -68,6 +70,7 @@ export const Header: React.FC<HeaderProps> = ({
}) => (
<Container data-test-subj={dataTestSubj}>
<Wrapper maxWidth={maxWidth}>
{topContent}
<HeaderColumns
leftColumn={leftColumn}
rightColumn={rightColumn}

View file

@ -8,7 +8,7 @@
import { appRoutesService } from '../../services';
import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../types';
import { sendRequest } from './use_request';
import { sendRequest, useRequest } from './use_request';
export const sendGetPermissionsCheck = () => {
return sendRequest<CheckPermissionsResponse>({
@ -23,3 +23,10 @@ export const sendGenerateServiceToken = () => {
method: 'post',
});
};
export const usePermissionCheck = () => {
return useRequest<CheckPermissionsResponse>({
path: appRoutesService.getCheckPermissionsPath(),
method: 'get',
});
};

View file

@ -37,7 +37,8 @@ export const createAppContextStartContractMock = (): FleetAppContext => {
data: dataPluginMock.createStartContract(),
encryptedSavedObjectsStart: encryptedSavedObjectsMock.createStart(),
savedObjects: savedObjectsServiceMock.createStartContract(),
security: securityMock.createStart(),
securitySetup: securityMock.createSetup(),
securityStart: securityMock.createStart(),
logger: loggingSystemMock.create().get(),
isProductionMode: true,
configInitialValue: {

View file

@ -37,6 +37,7 @@ import {
AGENT_POLICY_SAVED_OBJECT_TYPE,
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
PACKAGES_SAVED_OBJECT_TYPE,
ASSETS_SAVED_OBJECT_TYPE,
AGENT_SAVED_OBJECT_TYPE,
ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE,
PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE,
@ -103,7 +104,8 @@ export interface FleetAppContext {
data: DataPluginStart;
encryptedSavedObjectsStart?: EncryptedSavedObjectsPluginStart;
encryptedSavedObjectsSetup?: EncryptedSavedObjectsPluginSetup;
security?: SecurityPluginStart;
securitySetup?: SecurityPluginSetup;
securityStart?: SecurityPluginStart;
config$?: Observable<FleetConfigType>;
configInitialValue: FleetConfigType;
savedObjects: SavedObjectsServiceStart;
@ -122,6 +124,7 @@ const allSavedObjectTypes = [
AGENT_POLICY_SAVED_OBJECT_TYPE,
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
PACKAGES_SAVED_OBJECT_TYPE,
ASSETS_SAVED_OBJECT_TYPE,
AGENT_SAVED_OBJECT_TYPE,
ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE,
PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE,
@ -164,14 +167,15 @@ export class FleetPlugin
private licensing$!: Observable<ILicense>;
private config$: Observable<FleetConfigType>;
private configInitialValue: FleetConfigType;
private cloud: CloudSetup | undefined;
private logger: Logger | undefined;
private cloud?: CloudSetup;
private logger?: Logger;
private isProductionMode: FleetAppContext['isProductionMode'];
private kibanaVersion: FleetAppContext['kibanaVersion'];
private kibanaBranch: FleetAppContext['kibanaBranch'];
private httpSetup: HttpServiceSetup | undefined;
private encryptedSavedObjectsSetup: EncryptedSavedObjectsPluginSetup | undefined;
private httpSetup?: HttpServiceSetup;
private securitySetup?: SecurityPluginSetup;
private encryptedSavedObjectsSetup?: EncryptedSavedObjectsPluginSetup;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config$ = this.initializerContext.config.create<FleetConfigType>();
@ -187,6 +191,7 @@ export class FleetPlugin
this.licensing$ = deps.licensing.license$;
this.encryptedSavedObjectsSetup = deps.encryptedSavedObjects;
this.cloud = deps.cloud;
this.securitySetup = deps.security;
const config = this.configInitialValue;
registerSavedObjects(core.savedObjects, deps.encryptedSavedObjects);
@ -233,6 +238,10 @@ export class FleetPlugin
// Always register app routes for permissions checking
registerAppRoutes(router);
// Allow read-only users access to endpoints necessary for Integrations UI
// Only some endpoints require superuser so we pass a raw IRouter here
registerEPMRoutes(router);
// For all the routes we enforce the user to have role superuser
const routerSuperuserOnly = makeRouterEnforcingSuperuser(router);
// Register rest of routes only if security is enabled
@ -243,7 +252,6 @@ export class FleetPlugin
registerOutputRoutes(routerSuperuserOnly);
registerSettingsRoutes(routerSuperuserOnly);
registerDataStreamRoutes(routerSuperuserOnly);
registerEPMRoutes(routerSuperuserOnly);
registerPreconfigurationRoutes(routerSuperuserOnly);
// Conditional config routes
@ -260,7 +268,8 @@ export class FleetPlugin
data: plugins.data,
encryptedSavedObjectsStart: plugins.encryptedSavedObjects,
encryptedSavedObjectsSetup: this.encryptedSavedObjectsSetup,
security: plugins.security,
securitySetup: this.securitySetup,
securityStart: plugins.security,
configInitialValue: this.configInitialValue,
config$: this.config$,
savedObjects: core.savedObjects,

View file

@ -17,13 +17,15 @@ export const getCheckPermissionsHandler: RequestHandler = async (context, reques
success: false,
error: 'MISSING_SECURITY',
};
const body: CheckPermissionsResponse = { success: true };
try {
const security = await appContextService.getSecurity();
if (!appContextService.hasSecurity() || !appContextService.getSecurityLicense().isEnabled()) {
return response.ok({ body: missingSecurityBody });
} else {
const security = appContextService.getSecurity();
const user = security.authc.getCurrentUser(request);
// when ES security is disabled, but Kibana security plugin is not explicitly disabled,
// `authc.getCurrentUser()` does not error, instead it comes back as `null`
// Defensively handle situation where user is undefined (should only happen when ES security is disabled)
// This should be covered by the `getSecurityLicense().isEnabled()` check above, but we leave this for robustness.
if (!user) {
return response.ok({
body: missingSecurityBody,
@ -31,20 +33,15 @@ export const getCheckPermissionsHandler: RequestHandler = async (context, reques
}
if (!user?.roles.includes('superuser')) {
body.success = false;
body.error = 'MISSING_SUPERUSER_ROLE';
return response.ok({
body,
body: {
success: false,
error: 'MISSING_SUPERUSER_ROLE',
} as CheckPermissionsResponse,
});
}
return response.ok({ body: { success: true } });
} catch (e) {
// when Kibana security plugin is explicitly disabled,
// `appContextService.getSecurity()` returns an error, so we catch it here
return response.ok({
body: missingSecurityBody,
});
return response.ok({ body: { success: true } as CheckPermissionsResponse });
}
};

View file

@ -20,6 +20,7 @@ import {
GetStatsRequestSchema,
UpdatePackageRequestSchema,
} from '../../types';
import { enforceSuperUser } from '../security';
import {
getCategoriesHandler,
@ -60,7 +61,7 @@ export const registerRoutes = (router: IRouter) => {
{
path: EPM_API_ROUTES.LIMITED_LIST_PATTERN,
validate: false,
options: { tags: [`access:${PLUGIN_ID}`] },
options: { tags: [`access:${PLUGIN_ID}-read`] },
},
getLimitedListHandler
);
@ -69,7 +70,7 @@ export const registerRoutes = (router: IRouter) => {
{
path: EPM_API_ROUTES.STATS_PATTERN,
validate: GetStatsRequestSchema,
options: { tags: [`access:${PLUGIN_ID}`] },
options: { tags: [`access:${PLUGIN_ID}-read`] },
},
getStatsHandler
);
@ -98,7 +99,7 @@ export const registerRoutes = (router: IRouter) => {
validate: UpdatePackageRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
updatePackageHandler
enforceSuperUser(updatePackageHandler)
);
router.post(
@ -107,7 +108,7 @@ export const registerRoutes = (router: IRouter) => {
validate: InstallPackageFromRegistryRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
installPackageFromRegistryHandler
enforceSuperUser(installPackageFromRegistryHandler)
);
router.post(
@ -116,7 +117,7 @@ export const registerRoutes = (router: IRouter) => {
validate: BulkUpgradePackagesFromRegistryRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
bulkInstallPackagesFromRegistryHandler
enforceSuperUser(bulkInstallPackagesFromRegistryHandler)
);
router.post(
@ -132,7 +133,7 @@ export const registerRoutes = (router: IRouter) => {
},
},
},
installPackageByUploadHandler
enforceSuperUser(installPackageByUploadHandler)
);
router.delete(
@ -141,6 +142,6 @@ export const registerRoutes = (router: IRouter) => {
validate: DeletePackageRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
deletePackageHandler
enforceSuperUser(deletePackageHandler)
);
};

View file

@ -13,6 +13,14 @@ export function enforceSuperUser<T1, T2, T3>(
handler: RequestHandler<T1, T2, T3>
): RequestHandler<T1, T2, T3> {
return function enforceSuperHandler(context, req, res) {
if (!appContextService.hasSecurity() || !appContextService.getSecurityLicense().isEnabled()) {
return res.forbidden({
body: {
message: `Access to this API requires that security is enabled`,
},
});
}
const security = appContextService.getSecurity();
const user = security.authc.getCurrentUser(req);
if (!user) {

View file

@ -22,7 +22,7 @@ import type {
EncryptedSavedObjectsPluginSetup,
} from '../../../encrypted_saved_objects/server';
import type { SecurityPluginStart } from '../../../security/server';
import type { SecurityPluginStart, SecurityPluginSetup } from '../../../security/server';
import type { FleetConfigType } from '../../common';
import type {
ExternalCallback,
@ -39,7 +39,8 @@ class AppContextService {
private encryptedSavedObjectsSetup: EncryptedSavedObjectsPluginSetup | undefined;
private data: DataPluginStart | undefined;
private esClient: ElasticsearchClient | undefined;
private security: SecurityPluginStart | undefined;
private securitySetup: SecurityPluginSetup | undefined;
private securityStart: SecurityPluginStart | undefined;
private config$?: Observable<FleetConfigType>;
private configSubject$?: BehaviorSubject<FleetConfigType>;
private savedObjects: SavedObjectsServiceStart | undefined;
@ -56,7 +57,8 @@ class AppContextService {
this.esClient = appContext.elasticsearch.client.asInternalUser;
this.encryptedSavedObjects = appContext.encryptedSavedObjectsStart?.getClient();
this.encryptedSavedObjectsSetup = appContext.encryptedSavedObjectsSetup;
this.security = appContext.security;
this.securitySetup = appContext.securitySetup;
this.securityStart = appContext.securityStart;
this.savedObjects = appContext.savedObjects;
this.isProductionMode = appContext.isProductionMode;
this.cloud = appContext.cloud;
@ -92,14 +94,21 @@ class AppContextService {
}
public getSecurity() {
if (!this.security) {
if (!this.hasSecurity()) {
throw new Error('Security service not set.');
}
return this.security;
return this.securityStart!;
}
public getSecurityLicense() {
if (!this.hasSecurity()) {
throw new Error('Security service not set.');
}
return this.securitySetup!.license;
}
public hasSecurity() {
return !!this.security;
return !!this.securitySetup && !!this.securityStart;
}
public getCloud() {

View file

@ -67,3 +67,11 @@ export const setPackageInfo = ({
};
export const deletePackageInfo = (args: SharedKey) => packageInfoCache.delete(sharedKey(args));
export const clearPackageFileCache = (args: SharedKey) => {
const fileList = getArchiveFilelist(args) ?? [];
fileList.forEach((filePath) => {
deleteArchiveEntry(filePath);
});
deleteArchiveFilelist(args);
};

View file

@ -16,6 +16,7 @@ import {
setArchiveFilelist,
deleteArchiveFilelist,
deletePackageInfo,
clearPackageFileCache,
} from './cache';
import type { SharedKey } from './cache';
import { getBufferExtractor } from './extract';
@ -42,6 +43,9 @@ export async function unpackBufferToCache({
archiveBuffer: Buffer;
installSource: InstallSource;
}): Promise<string[]> {
// Make sure any buffers from previous installations from registry or upload are deleted first
clearPackageFileCache({ name, version });
const entries = await unpackBufferEntries(archiveBuffer, contentType);
const paths: string[] = [];
entries.forEach((entry) => {

View file

@ -75,6 +75,10 @@ export const getHttp = (basepath = BASE_PATH) => {
return await import('./fixtures/integration.okta');
}
if (path.startsWith('/api/fleet/check-permissions')) {
return { success: true };
}
action(path)('KP: UNSUPPORTED ROUTE');
return {};
}) as HttpHandler,

View file

@ -10873,8 +10873,6 @@
"xpack.fleet.integrations.updatePackage.updatePackageButtonLabel": "最新バージョンに更新",
"xpack.fleet.integrationsAppTitle": "統合",
"xpack.fleet.integrationsHeaderTitle": "Elasticエージェント統合",
"xpack.fleet.integrationsPermissionDeniedErrorMessage": "統合にアクセスする権限がありません。統合には{roleName}権限が必要です。",
"xpack.fleet.integrationsSecurityRequiredErrorMessage": "統合を使用するには、KibanaとElasticsearchでセキュリティを有効にする必要があります。",
"xpack.fleet.invalidLicenseDescription": "現在のライセンスは期限切れです。登録されたビートエージェントは引き続き動作しますが、Elastic Fleet インターフェイスにアクセスするには有効なライセンスが必要です。",
"xpack.fleet.invalidLicenseTitle": "ライセンスの期限切れ",
"xpack.fleet.multiTextInput.addRow": "行の追加",
@ -10946,7 +10944,6 @@
"xpack.fleet.preconfiguration.missingIDError": "{agentPolicyName}には「id」フィールドがありません。ポリシーのis_defaultまたはis_default_fleet_serverに設定されている場合をのぞき、「id」は必須です。",
"xpack.fleet.preconfiguration.packageMissingError": "{agentPolicyName}を追加できませんでした。{pkgName}がインストールされていません。{pkgName}を`{packagesConfigValue}`に追加するか、{packagePolicyName}から削除してください。",
"xpack.fleet.preconfiguration.policyDeleted": "構成済みのポリシー{id}が削除されました。作成をスキップしています",
"xpack.fleet.securityRequiredErrorTitle": "セキュリティが有効ではありません",
"xpack.fleet.serverError.agentPolicyDoesNotExist": "エージェントポリシー{agentPolicyId}が存在しません",
"xpack.fleet.serverError.enrollmentKeyDuplicate": "エージェントポリシーの{agentPolicyId}登録キー{providedKeyName}はすでに存在します",
"xpack.fleet.serverError.returnedIncorrectKey": "find enrollmentKeyByIdで正しくないキーが返されました",

View file

@ -10987,8 +10987,6 @@
"xpack.fleet.integrations.updatePackage.updatePackageButtonLabel": "更新到最新版本",
"xpack.fleet.integrationsAppTitle": "集成",
"xpack.fleet.integrationsHeaderTitle": "Elastic 代理集成",
"xpack.fleet.integrationsPermissionDeniedErrorMessage": "您无权访问“集成”。“集成”需要 {roleName} 权限。",
"xpack.fleet.integrationsSecurityRequiredErrorMessage": "必须在 Kibana 和 Elasticsearch 中启用安全性,才能使用“集成”。",
"xpack.fleet.invalidLicenseDescription": "您当前的许可证已过期。已注册 Beats 代理将继续工作,但您需要有效的许可证,才能访问 Elastic Fleet 界面。",
"xpack.fleet.invalidLicenseTitle": "已过期许可证",
"xpack.fleet.multiTextInput.addRow": "添加行",
@ -11060,7 +11058,6 @@
"xpack.fleet.preconfiguration.missingIDError": "{agentPolicyName} 缺失 `id` 字段。`id` 是必需的,但标记为 is_default 或 is_default_fleet_server 的策略除外。",
"xpack.fleet.preconfiguration.packageMissingError": "{agentPolicyName} 无法添加。{pkgName} 未安装,请将 {pkgName} 添加到 `{packagesConfigValue}` 或将其从 {packagePolicyName} 中移除。",
"xpack.fleet.preconfiguration.policyDeleted": "预配置的策略 {id} 已删除;将跳过创建",
"xpack.fleet.securityRequiredErrorTitle": "安全性未启用",
"xpack.fleet.serverError.agentPolicyDoesNotExist": "代理策略 {agentPolicyId} 不存在",
"xpack.fleet.serverError.enrollmentKeyDuplicate": "称作 {providedKeyName} 的注册密钥对于代理策略 {agentPolicyId} 已存在",
"xpack.fleet.serverError.returnedIncorrectKey": "find enrollmentKeyById 返回错误的密钥",

View file

@ -8,66 +8,15 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { testUsers } from '../test_users';
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const supertest = getService('supertest');
const security = getService('security');
const users: { [rollName: string]: { username: string; password: string; permissions?: any } } = {
kibana_basic_user: {
permissions: {
feature: {
dashboards: ['read'],
},
spaces: ['*'],
},
username: 'kibana_basic_user',
password: 'changeme',
},
fleet_user: {
permissions: {
feature: {
fleet: ['read'],
},
spaces: ['*'],
},
username: 'fleet_user',
password: 'changeme',
},
fleet_admin: {
permissions: {
feature: {
fleet: ['all'],
},
spaces: ['*'],
},
username: 'fleet_admin',
password: 'changeme',
},
};
describe('fleet_list_agent', () => {
before(async () => {
for (const roleName in users) {
if (users.hasOwnProperty(roleName)) {
const user = users[roleName];
if (user.permissions) {
await security.role.create(roleName, {
kibana: [user.permissions],
});
}
// Import a repository first
await security.user.create(user.username, {
password: user.password,
roles: [roleName],
full_name: user.username,
});
}
}
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/fleet/agents');
});
after(async () => {
@ -77,13 +26,13 @@ export default function ({ getService }: FtrProviderContext) {
it('should return a 403 if a user without the superuser role try to access the APU', async () => {
await supertestWithoutAuth
.get(`/api/fleet/agents`)
.auth(users.fleet_admin.username, users.fleet_admin.password)
.auth(testUsers.fleet_all.username, testUsers.fleet_all.password)
.expect(403);
});
it('should not return the list of agents when requesting as a user without fleet permissions', async () => {
await supertestWithoutAuth
.get(`/api/fleet/agents`)
.auth(users.kibana_basic_user.username, users.kibana_basic_user.password)
.auth(testUsers.fleet_no_access.username, testUsers.fleet_no_access.password)
.expect(403);
});

View file

@ -14,10 +14,12 @@ import {
IBulkInstallPackageHTTPError,
} from '../../../../plugins/fleet/common';
import { setupFleetAndAgents } from '../agents/services';
import { testUsers } from '../test_users';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const deletePackage = async (pkgkey: string) => {
await supertest.delete(`/api/fleet/epm/packages/${pkgkey}`).set('kbn-xsrf', 'xxxx');
@ -44,6 +46,13 @@ export default function (providerContext: FtrProviderContext) {
it('should return 400 if no packages are requested for upgrade', async function () {
await supertest.post(`/api/fleet/epm/packages/_bulk`).set('kbn-xsrf', 'xxxx').expect(400);
});
it('should return 403 if read only user requests upgrade', async function () {
await supertestWithoutAuth
.post(`/api/fleet/epm/packages/_bulk`)
.auth(testUsers.fleet_read_only.username, testUsers.fleet_read_only.password)
.set('kbn-xsrf', 'xxxx')
.expect(403);
});
it('should return 200 and an array for upgrading a package', async function () {
const { body }: { body: BulkInstallPackagesResponse } = await supertest
.post(`/api/fleet/epm/packages/_bulk`)

View file

@ -8,10 +8,12 @@
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../../helpers';
import { setupFleetAndAgents } from '../agents/services';
import { testUsers } from '../test_users';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const requiredPackage = 'elastic_agent-0.0.7';
const installPackage = async (pkgkey: string) => {
@ -52,5 +54,13 @@ export default function (providerContext: FtrProviderContext) {
.send({ force: true })
.expect(200);
});
it('should return 403 for read-only users', async () => {
await supertestWithoutAuth
.delete(`/api/fleet/epm/packages/${requiredPackage}`)
.auth(testUsers.fleet_read_only.username, testUsers.fleet_read_only.password)
.set('kbn-xsrf', 'xxxx')
.expect(403);
});
});
}

View file

@ -11,11 +11,13 @@ import path from 'path';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../../helpers';
import { setupFleetAndAgents } from '../agents/services';
import { testUsers } from '../test_users';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const testPkgKey = 'apache-0.1.4';
@ -91,5 +93,12 @@ export default function (providerContext: FtrProviderContext) {
it('returns a 400 for a package key without a proper semver version', async function () {
await supertest.get('/api/fleet/epm/packages/endpoint-0.1.0.1.2.3').expect(400);
});
it('allows user with only read permission to access', async () => {
await supertestWithoutAuth
.get(`/api/fleet/epm/packages/${testPkgKey}`)
.auth(testUsers.fleet_read_only.username, testUsers.fleet_read_only.password)
.expect(200);
});
});
}

View file

@ -12,10 +12,12 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../../helpers';
import { setupFleetAndAgents } from '../agents/services';
import { testUsers } from '../test_users';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const dockerServers = getService('dockerServers');
const testPkgArchiveTgz = path.join(
@ -190,5 +192,16 @@ export default function (providerContext: FtrProviderContext) {
'{"statusCode":400,"error":"Bad Request","message":"Name thisIsATypo and version 0.1.4 do not match top-level directory apache-0.1.4"}'
);
});
it('should not allow users without all access', async () => {
const buf = fs.readFileSync(testPkgArchiveTgz);
await supertestWithoutAuth
.post(`/api/fleet/epm/packages`)
.auth(testUsers.fleet_read_only.username, testUsers.fleet_read_only.password)
.set('kbn-xsrf', 'xxxx')
.type('application/gzip')
.send(buf)
.expect(403);
});
});
}

View file

@ -9,10 +9,12 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../../helpers';
import { setupFleetAndAgents } from '../agents/services';
import { testUsers } from '../test_users';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
// use function () {} and not () => {} here
@ -54,6 +56,13 @@ export default function (providerContext: FtrProviderContext) {
expect(listResponse.response).to.eql(['endpoint']);
});
it('allows user with only read permission to access', async () => {
await supertestWithoutAuth
.get('/api/fleet/epm/packages')
.auth(testUsers.fleet_read_only.username, testUsers.fleet_read_only.password)
.expect(200);
});
});
});
}

View file

@ -5,10 +5,16 @@
* 2.0.
*/
export default function ({ loadTestFile }) {
import { setupTestUsers } from './test_users';
export default function ({ loadTestFile, getService }) {
describe('Fleet Endpoints', function () {
before(async () => {
await setupTestUsers(getService('security'));
});
// EPM
loadTestFile(require.resolve('./epm/index'));
loadTestFile(require.resolve('./epm'));
// Fleet setup
loadTestFile(require.resolve('./fleet_setup'));

View file

@ -0,0 +1,63 @@
/*
* 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 { SecurityService } from '../../../../test/common/services/security/security';
export const testUsers: {
[rollName: string]: { username: string; password: string; permissions?: any };
} = {
fleet_no_access: {
permissions: {
feature: {
dashboards: ['read'],
},
spaces: ['*'],
},
username: 'fleet_no_access',
password: 'changeme',
},
fleet_read_only: {
permissions: {
feature: {
fleet: ['read'],
},
spaces: ['*'],
},
username: 'fleet_read_only',
password: 'changeme',
},
fleet_all: {
permissions: {
feature: {
fleet: ['all'],
},
spaces: ['*'],
},
username: 'fleet_all',
password: 'changeme',
},
};
export const setupTestUsers = async (security: SecurityService) => {
for (const roleName in testUsers) {
if (testUsers.hasOwnProperty(roleName)) {
const user = testUsers[roleName];
if (user.permissions) {
await security.role.create(roleName, {
kibana: [user.permissions],
});
}
await security.user.create(user.username, {
password: user.password,
roles: [roleName],
full_name: user.username,
});
}
}
};