[Component templates] Privileges support (#68733)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Alison Goryachev 2020-06-12 11:44:26 -04:00 committed by GitHub
parent ccf8def829
commit 1a933c293d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 395 additions and 4 deletions

View file

@ -9,6 +9,7 @@
"management"
],
"optionalPlugins": [
"security",
"usageCollection"
],
"configPath": ["xpack", "index_management"]

View file

@ -15,7 +15,7 @@ import {
nextTick,
} from '../../../../../../../../../test_utils';
import { WithAppDependencies } from './setup_environment';
import { ComponentTemplateList } from '../../../component_template_list';
import { ComponentTemplateList } from '../../../component_template_list/component_template_list';
const testBedConfig: TestBedConfig = {
memoryRouter: {

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { AuthorizationProvider } from '../shared_imports';
import { useComponentTemplatesContext } from '../component_templates_context';
export const ComponentTemplatesAuthProvider: React.FunctionComponent = ({
children,
}: {
children?: React.ReactNode;
}) => {
const { httpClient, apiBasePath } = useComponentTemplatesContext();
return (
<AuthorizationProvider
privilegesEndpoint={`${apiBasePath}/component_templates/privileges`}
httpClient={httpClient}
>
{children}
</AuthorizationProvider>
);
};

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { ComponentTemplatesAuthProvider } from './auth_provider';
import { ComponentTemplatesWithPrivileges } from './with_privileges';
import { ComponentTemplateList } from './component_template_list';
export const ComponentTemplateListContainer: React.FunctionComponent = () => {
return (
<ComponentTemplatesAuthProvider>
<ComponentTemplatesWithPrivileges>
<ComponentTemplateList />
</ComponentTemplatesWithPrivileges>
</ComponentTemplatesAuthProvider>
);
};

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { ComponentTemplateList } from './component_template_list';
export { ComponentTemplateListContainer as ComponentTemplateList } from './component_template_list_container';

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import React, { FunctionComponent } from 'react';
import {
SectionError,
useAuthorizationContext,
WithPrivileges,
SectionLoading,
NotAuthorizedSection,
} from '../shared_imports';
import { APP_CLUSTER_REQUIRED_PRIVILEGES } from '../constants';
export const ComponentTemplatesWithPrivileges: FunctionComponent = ({
children,
}: {
children?: React.ReactNode;
}) => {
const { apiError } = useAuthorizationContext();
if (apiError) {
return (
<SectionError
title={
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.checkingPrivilegesErrorMessage"
defaultMessage="Error fetching user privileges from the server."
/>
}
error={apiError}
/>
);
}
return (
<WithPrivileges
privileges={APP_CLUSTER_REQUIRED_PRIVILEGES.map((privilege) => `cluster.${privilege}`)}
>
{({ isLoading, hasPrivileges, privilegesMissing }) => {
if (isLoading) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.checkingPrivilegesDescription"
defaultMessage="Checking privileges…"
/>
</SectionLoading>
);
}
if (!hasPrivileges) {
return (
<NotAuthorizedSection
title={
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.deniedPrivilegeTitle"
defaultMessage="Cluster privileges required"
/>
}
message={
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.deniedPrivilegeDescription"
defaultMessage="To use Component Templates, you must have {privilegesCount,
plural, one {this cluster privilege} other {these cluster privileges}}: {missingPrivileges}."
values={{
missingPrivileges: privilegesMissing.cluster!.join(', '),
privilegesCount: privilegesMissing.cluster!.length,
}}
/>
}
/>
);
}
return <>{children}</>;
}}
</WithPrivileges>
);
};

View file

@ -21,6 +21,8 @@ interface Props {
}
interface Context {
httpClient: HttpSetup;
apiBasePath: string;
api: ReturnType<typeof getApi>;
documentation: ReturnType<typeof getDocumentation>;
trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void;
@ -45,7 +47,7 @@ export const ComponentTemplatesProvider = ({
return (
<ComponentTemplatesContext.Provider
value={{ api, documentation, trackMetric, toasts, appBasePath }}
value={{ api, documentation, trackMetric, toasts, appBasePath, httpClient, apiBasePath }}
>
{children}
</ComponentTemplatesContext.Provider>

View file

@ -8,3 +8,6 @@
export const UIM_COMPONENT_TEMPLATE_LIST_LOAD = 'component_template_list_load';
export const UIM_COMPONENT_TEMPLATE_DELETE = 'component_template_delete';
export const UIM_COMPONENT_TEMPLATE_DELETE_MANY = 'component_template_delete_many';
// privileges
export const APP_CLUSTER_REQUIRED_PRIVILEGES = ['manage_index_templates'];

View file

@ -12,4 +12,10 @@ export {
sendRequest,
useRequest,
SectionLoading,
WithPrivileges,
AuthorizationProvider,
SectionError,
Error,
useAuthorizationContext,
NotAuthorizedSection,
} from '../../../../../../../src/plugins/es_ui_shared/public';

View file

@ -59,7 +59,7 @@ export class IndexMgmtServerPlugin implements Plugin<IndexManagementPluginSetup,
setup(
{ http, getStartServices }: CoreSetup,
{ licensing }: Dependencies
{ licensing, security }: Dependencies
): IndexManagementPluginSetup {
const router = http.createRouter();
@ -89,6 +89,9 @@ export class IndexMgmtServerPlugin implements Plugin<IndexManagementPluginSetup,
this.apiRoutes.setup({
router,
license: this.license,
config: {
isSecurityEnabled: () => security !== undefined && security.license.isEnabled(),
},
indexDataEnricher: this.indexDataEnricher,
lib: {
isEsError,

View file

@ -10,10 +10,12 @@ import { registerGetAllRoute } from './get';
import { registerCreateRoute } from './create';
import { registerUpdateRoute } from './update';
import { registerDeleteRoute } from './delete';
import { registerPrivilegesRoute } from './privileges';
export function registerComponentTemplateRoutes(dependencies: RouteDependencies) {
registerGetAllRoute(dependencies);
registerCreateRoute(dependencies);
registerUpdateRoute(dependencies);
registerDeleteRoute(dependencies);
registerPrivilegesRoute(dependencies);
}

View file

@ -0,0 +1,153 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { httpServerMock, httpServiceMock } from 'src/core/server/mocks';
import {
kibanaResponseFactory,
RequestHandlerContext,
RequestHandler,
IRouter,
} from 'src/core/server';
import { License } from '../../../services/license';
import { IndexDataEnricher } from '../../../services/index_data_enricher';
import { registerPrivilegesRoute } from './privileges';
jest.mock('../../../services/index_data_enricher');
const httpService = httpServiceMock.createSetupContract();
const mockedIndexDataEnricher = new IndexDataEnricher();
const mockRouteContext = ({
callAsCurrentUser,
}: {
callAsCurrentUser: any;
}): RequestHandlerContext => {
const routeContextMock = ({
core: {
elasticsearch: {
legacy: {
client: {
callAsCurrentUser,
},
},
},
},
} as unknown) as RequestHandlerContext;
return routeContextMock;
};
describe('GET privileges', () => {
let routeHandler: RequestHandler<any, any, any>;
beforeEach(() => {
const router = httpService.createRouter('') as jest.Mocked<IRouter>;
registerPrivilegesRoute({
router,
license: {
guardApiRoute: (route: any) => route,
} as License,
config: {
isSecurityEnabled: () => true,
},
indexDataEnricher: mockedIndexDataEnricher,
lib: {
isEsError: jest.fn(),
},
});
routeHandler = router.get.mock.calls[0][1];
});
it('should return the correct response when a user has privileges', async () => {
const privilegesResponseMock = {
username: 'elastic',
has_all_requested: true,
cluster: { manage_index_templates: true },
index: {},
application: {},
};
const routeContextMock = mockRouteContext({
callAsCurrentUser: jest.fn().mockResolvedValueOnce(privilegesResponseMock),
});
const request = httpServerMock.createKibanaRequest();
const response = await routeHandler(routeContextMock, request, kibanaResponseFactory);
expect(response.payload).toEqual({
hasAllPrivileges: true,
missingPrivileges: {
cluster: [],
},
});
});
it('should return the correct response when a user does not have privileges', async () => {
const privilegesResponseMock = {
username: 'elastic',
has_all_requested: false,
cluster: { manage_index_templates: false },
index: {},
application: {},
};
const routeContextMock = mockRouteContext({
callAsCurrentUser: jest.fn().mockResolvedValueOnce(privilegesResponseMock),
});
const request = httpServerMock.createKibanaRequest();
const response = await routeHandler(routeContextMock, request, kibanaResponseFactory);
expect(response.payload).toEqual({
hasAllPrivileges: false,
missingPrivileges: {
cluster: ['manage_index_templates'],
},
});
});
describe('With security disabled', () => {
beforeEach(() => {
const router = httpService.createRouter('') as jest.Mocked<IRouter>;
registerPrivilegesRoute({
router,
license: {
guardApiRoute: (route: any) => route,
} as License,
config: {
isSecurityEnabled: () => false,
},
indexDataEnricher: mockedIndexDataEnricher,
lib: {
isEsError: jest.fn(),
},
});
routeHandler = router.get.mock.calls[0][1];
});
it('should return the default privileges response', async () => {
const routeContextMock = mockRouteContext({
callAsCurrentUser: jest.fn(),
});
const request = httpServerMock.createKibanaRequest();
const response = await routeHandler(routeContextMock, request, kibanaResponseFactory);
expect(response.payload).toEqual({
hasAllPrivileges: true,
missingPrivileges: {
cluster: [],
},
});
});
});
});

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Privileges } from 'src/plugins/es_ui_shared/public';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../index';
const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = {}): string[] =>
Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => {
if (!privilegesObject[privilegeName]) {
privileges.push(privilegeName);
}
return privileges;
}, []);
export const registerPrivilegesRoute = ({ license, router, config }: RouteDependencies) => {
router.get(
{
path: addBasePath('/component_templates/privileges'),
validate: false,
},
license.guardApiRoute(async (ctx, req, res) => {
const privilegesResult: Privileges = {
hasAllPrivileges: true,
missingPrivileges: {
cluster: [],
},
};
// Skip the privileges check if security is not enabled
if (!config.isSecurityEnabled()) {
return res.ok({ body: privilegesResult });
}
const {
core: {
elasticsearch: {
legacy: { client },
},
},
} = ctx;
try {
const { has_all_requested: hasAllPrivileges, cluster } = await client.callAsCurrentUser(
'transport.request',
{
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
cluster: ['manage_index_templates'],
},
}
);
if (!hasAllPrivileges) {
privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(cluster);
}
privilegesResult.hasAllPrivileges = hasAllPrivileges;
return res.ok({ body: privilegesResult });
} catch (e) {
return res.internalError({ body: e });
}
})
);
};

View file

@ -5,16 +5,21 @@
*/
import { ScopedClusterClient, IRouter } from 'src/core/server';
import { LicensingPluginSetup } from '../../licensing/server';
import { SecurityPluginSetup } from '../../security/server';
import { License, IndexDataEnricher } from './services';
import { isEsError } from './lib/is_es_error';
export interface Dependencies {
security: SecurityPluginSetup;
licensing: LicensingPluginSetup;
}
export interface RouteDependencies {
router: IRouter;
license: License;
config: {
isSecurityEnabled: () => boolean;
};
indexDataEnricher: IndexDataEnricher;
lib: {
isEsError: typeof isEsError;

View file

@ -338,5 +338,20 @@ export default function ({ getService }: FtrProviderContext) {
expect(body.errors[0].error.msg).to.contain('index_template_missing_exception');
});
});
describe('Privileges', () => {
it('should return privileges result', async () => {
const uri = `${API_BASE_PATH}/component_templates/privileges`;
const { body } = await supertest.get(uri).set('kbn-xsrf', 'xxx').expect(200);
expect(body).to.eql({
hasAllPrivileges: true,
missingPrivileges: {
cluster: [],
},
});
});
});
});
}