[ES-UI] Authorization lib to control access to feature & app sections (#40150)

This commit is contained in:
Sébastien Loix 2019-07-29 19:06:14 +02:00 committed by GitHub
parent 6c36fa103a
commit a45f28fa96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 317 additions and 264 deletions

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './app';
export * from './repository';
export * from './snapshot';
export * from './restore';

View file

@ -4,16 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useRef } from 'react';
import React, { useContext } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui';
import { EuiPageContent } from '@elastic/eui';
import { SectionLoading, SectionError } from './components';
import { BASE_PATH, DEFAULT_SECTION, Section } from './constants';
import { RepositoryAdd, RepositoryEdit, RestoreSnapshot, SnapshotRestoreHome } from './sections';
import { useLoadPermissions } from './services/http';
import { useAppState } from './services/state';
import { useAppDependencies } from './index';
import { AuthorizationContext, WithPrivileges, NotAuthorizedSection } from './lib/authorization';
export const App: React.FunctionComponent = () => {
const {
@ -21,116 +20,82 @@ export const App: React.FunctionComponent = () => {
i18n: { FormattedMessage },
},
} = useAppDependencies();
// Get app state to set permissions data
const [, dispatch] = useAppState();
// Use ref for default permission data so that re-rendering doesn't
// cause dispatch to be called over and over
const defaultPermissionsData = useRef({
hasPermission: true,
missingClusterPrivileges: [],
missingIndexPrivileges: [],
});
// Load permissions
const {
error: permissionsError,
loading: loadingPermissions,
data: permissionsData = defaultPermissionsData.current,
} = useLoadPermissions();
const { hasPermission, missingClusterPrivileges } = permissionsData;
// Update app state with permissions data
useEffect(() => {
dispatch({
type: 'updatePermissions',
permissions: permissionsData,
});
}, [permissionsData]);
if (loadingPermissions) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.app.checkingPermissionsDescription"
defaultMessage="Checking permissions…"
/>
</SectionLoading>
);
}
if (permissionsError) {
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.app.checkingPermissionsErrorMessage"
defaultMessage="Error checking permissions"
/>
}
error={permissionsError}
/>
);
}
if (!hasPermission) {
return (
<EuiPageContent horizontalPosition="center">
<EuiEmptyPrompt
iconType="securityApp"
title={
<h2>
<FormattedMessage
id="xpack.snapshotRestore.app.deniedPermissionTitle"
defaultMessage="You're missing cluster privileges"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.snapshotRestore.app.deniedPermissionDescription"
defaultMessage="To use Snapshot and Restore, you must have {clusterPrivilegesCount,
plural, one {this cluster privilege} other {these cluster privileges}}: {clusterPrivileges}."
values={{
clusterPrivileges: missingClusterPrivileges.join(', '),
clusterPrivilegesCount: missingClusterPrivileges.length,
}}
/>
</p>
}
/>
</EuiPageContent>
);
}
const { apiError } = useContext(AuthorizationContext);
const sections: Section[] = ['repositories', 'snapshots', 'restore_status', 'policies'];
const sectionsRegex = sections.join('|');
return (
<div data-test-subj="snapshotRestoreApp">
<Switch>
<Route exact path={`${BASE_PATH}/add_repository`} component={RepositoryAdd} />
<Route exact path={`${BASE_PATH}/edit_repository/:name*`} component={RepositoryEdit} />
<Route
exact
path={`${BASE_PATH}/:section(${sectionsRegex})/:repositoryName?/:snapshotId*`}
component={SnapshotRestoreHome}
return apiError ? (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.app.checkingPrivilegesErrorMessage"
defaultMessage="Error fetching user privileges from the server."
/>
<Redirect
exact
from={`${BASE_PATH}/restore/:repositoryName`}
to={`${BASE_PATH}/snapshots`}
/>
<Route
exact
path={`${BASE_PATH}/restore/:repositoryName/:snapshotId*`}
component={RestoreSnapshot}
/>
<Redirect from={`${BASE_PATH}`} to={`${BASE_PATH}/${DEFAULT_SECTION}`} />
</Switch>
</div>
}
error={apiError}
/>
) : (
<WithPrivileges privileges="cluster.*">
{({ isLoading, hasPrivileges, privilegesMissing }) =>
isLoading ? (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.app.checkingPrivilegesDescription"
defaultMessage="Checking privileges…"
/>
</SectionLoading>
) : hasPrivileges ? (
<div data-test-subj="snapshotRestoreApp">
<Switch>
<Route exact path={`${BASE_PATH}/add_repository`} component={RepositoryAdd} />
<Route
exact
path={`${BASE_PATH}/edit_repository/:name*`}
component={RepositoryEdit}
/>
<Route
exact
path={`${BASE_PATH}/:section(${sectionsRegex})/:repositoryName?/:snapshotId*`}
component={SnapshotRestoreHome}
/>
<Redirect
exact
from={`${BASE_PATH}/restore/:repositoryName`}
to={`${BASE_PATH}/snapshots`}
/>
<Route
exact
path={`${BASE_PATH}/restore/:repositoryName/:snapshotId*`}
component={RestoreSnapshot}
/>
<Redirect from={`${BASE_PATH}`} to={`${BASE_PATH}/${DEFAULT_SECTION}`} />
</Switch>
</div>
) : (
<EuiPageContent>
<NotAuthorizedSection
title={
<FormattedMessage
id="xpack.snapshotRestore.app.deniedPrivilegeTitle"
defaultMessage="You're missing cluster privileges"
/>
}
message={
<FormattedMessage
id="xpack.snapshotRestore.app.deniedPrivilegeDescription"
defaultMessage="To use Snapshot and Restore, you must have {privilegesCount,
plural, one {this cluster privilege} other {these cluster privileges}}: {missingPrivileges}."
values={{
missingPrivileges: privilegesMissing.cluster!.join(', '),
privilegesCount: privilegesMissing.cluster!.length,
}}
/>
}
/>
</EuiPageContent>
)
}
</WithPrivileges>
);
};

View file

@ -4,12 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import React, { createContext, useContext, ReactNode } from 'react';
import { render } from 'react-dom';
import { HashRouter } from 'react-router-dom';
import { API_BASE_PATH } from '../../common/constants';
import { App } from './app';
import { AppStateProvider, initialState, reducer } from './services/state';
import { httpService } from './services/http';
import { AuthorizationProvider } from './lib/authorization';
import { AppCore, AppDependencies, AppPlugins } from './types';
export { BASE_PATH as CLIENT_BASE_PATH } from './constants';
@ -41,13 +43,15 @@ const getAppProviders = (deps: AppDependencies) => {
const AppDependenciesProvider = setAppDependencies(deps);
return ({ children }: { children: ReactNode }) => (
<I18nContext>
<HashRouter>
<AppDependenciesProvider value={deps}>
<AppStateProvider value={useReducer(reducer, initialState)}>{children}</AppStateProvider>
</AppDependenciesProvider>
</HashRouter>
</I18nContext>
<AuthorizationProvider
privilegesEndpoint={httpService.addBasePath(`${API_BASE_PATH}privileges`)}
>
<I18nContext>
<HashRouter>
<AppDependenciesProvider value={deps}>{children}</AppDependenciesProvider>
</HashRouter>
</I18nContext>
</AuthorizationProvider>
);
};

View file

@ -0,0 +1,60 @@
/*
* 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, { createContext } from 'react';
import { useRequest } from '../../../services/http/use_request';
interface Authorization {
isLoading: boolean;
apiError: {
data: {
error: string;
cause?: string[];
message?: string;
};
} | null;
privileges: Privileges;
}
export interface Privileges {
hasAllPrivileges: boolean;
missingPrivileges: MissingPrivileges;
}
export interface MissingPrivileges {
[key: string]: string[] | undefined;
}
const initialValue: Authorization = {
isLoading: true,
apiError: null,
privileges: {
hasAllPrivileges: true,
missingPrivileges: {},
},
};
export const AuthorizationContext = createContext<Authorization>(initialValue);
interface Props {
privilegesEndpoint: string;
children: React.ReactNode;
}
export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) => {
const { loading, error, data: privilegesData } = useRequest({
path: privilegesEndpoint,
method: 'get',
});
const value = {
isLoading: loading,
privileges: loading ? { hasAllPrivileges: true, missingPrivileges: {} } : privilegesData,
apiError: error ? error : null,
};
return <AuthorizationContext.Provider value={value}>{children}</AuthorizationContext.Provider>;
};

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
export interface AppPermissions {
hasPermission: boolean;
missingClusterPrivileges: string[];
missingIndexPrivileges: string[];
}
export { AuthorizationProvider, AuthorizationContext, Privileges } from './authorization_provider';
export { WithPrivileges } from './with_privileges';
export { NotAuthorizedSection } from './not_authorized_section';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiEmptyPrompt } from '@elastic/eui';
interface Props {
title: React.ReactNode;
message: React.ReactNode | string;
}
export const NotAuthorizedSection = ({ title, message }: Props) => (
<EuiEmptyPrompt iconType="securityApp" title={<h2>{title}</h2>} body={<p>{message}</p>} />
);

View file

@ -0,0 +1,80 @@
/*
* 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 { useContext } from 'react';
import { AuthorizationContext, MissingPrivileges } from './authorization_provider';
interface Props {
/**
* Each required privilege must have the format "section.privilege".
* To indicate that *all* privileges from a section are required, we can use the asterix
* e.g. "index.*"
*/
privileges: string | string[];
children: (childrenProps: {
isLoading: boolean;
hasPrivileges: boolean;
privilegesMissing: MissingPrivileges;
}) => JSX.Element;
}
type Privilege = [string, string];
const toArray = (value: string | string[]): string[] =>
Array.isArray(value) ? (value as string[]) : ([value] as string[]);
export const WithPrivileges = ({ privileges: requiredPrivileges, children }: Props) => {
const { isLoading, privileges } = useContext(AuthorizationContext);
const privilegesToArray: Privilege[] = toArray(requiredPrivileges).map(p => {
const [section, privilege] = p.split('.');
if (!privilege) {
// Oh! we forgot to use the dot "." notation.
throw new Error('Required privilege must have the format "section.privilege"');
}
return [section, privilege];
});
const hasPrivileges = isLoading
? false
: privilegesToArray.every(privilege => {
const [section, requiredPrivilege] = privilege;
if (!privileges.missingPrivileges[section]) {
// if the section does not exist in our missingPriviledges, everything is OK
return true;
}
if (privileges.missingPrivileges[section]!.length === 0) {
return true;
}
if (requiredPrivilege === '*') {
// If length > 0 and we require them all... KO
return false;
}
// If we require _some_ privilege, we make sure that the one
// we require is *not* in the missingPrivilege array
return !privileges.missingPrivileges[section]!.includes(requiredPrivilege);
});
const privilegesMissing = privilegesToArray.reduce(
(acc, [section, privilege]) => {
if (privilege === '*') {
acc[section] = privileges.missingPrivileges[section] || [];
} else if (
privileges.missingPrivileges[section] &&
privileges.missingPrivileges[section]!.includes(privilege)
) {
const missing: string[] = acc[section] || [];
acc[section] = [...missing, privilege];
}
return acc;
},
{} as MissingPrivileges
);
return children({ isLoading, hasPrivileges, privilegesMissing });
};

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { initialState, reducer, AppStateProvider, useAppState } from './app_state';
export * from './components';

View file

@ -21,10 +21,10 @@ import { SectionError, SectionLoading } from '../../../components';
import { UIM_RESTORE_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { useLoadRestores } from '../../../services/http';
import { useAppState } from '../../../services/state';
import { uiMetricService } from '../../../services/ui_metric';
import { linkToSnapshots } from '../../../services/navigation';
import { RestoreTable } from './restore_table';
import { WithPrivileges, NotAuthorizedSection } from '../../../lib/authorization';
const ONE_SECOND_MS = 1000;
const TEN_SECONDS_MS = 10 * 1000;
@ -45,40 +45,6 @@ export const RestoreList: React.FunctionComponent = () => {
},
} = useAppDependencies();
// Check that we have all index privileges needed to view recovery information
const [appState] = useAppState();
const { permissions: { missingIndexPrivileges } = { missingIndexPrivileges: [] } } = appState;
// Render permission missing screen
if (missingIndexPrivileges.length) {
return (
<EuiEmptyPrompt
iconType="securityApp"
title={
<h2>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.deniedPermissionTitle"
defaultMessage="You're missing index privileges"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.deniedPermissionDescription"
defaultMessage="To view snapshot restore status, you must have {indexPrivilegesCount,
plural, one {this index privilege} other {these index privileges}} for one or more indices: {indexPrivileges}."
values={{
indexPrivileges: missingIndexPrivileges.join(', '),
indexPrivilegesCount: missingIndexPrivileges.length,
}}
/>
</p>
}
/>
);
}
// State for tracking interval picker
const [isIntervalMenuOpen, setIsIntervalMenuOpen] = useState<boolean>(false);
const [currentInterval, setCurrentInterval] = useState<number>(INTERVAL_OPTIONS[1]);
@ -94,7 +60,7 @@ export const RestoreList: React.FunctionComponent = () => {
trackUiMetric(UIM_RESTORE_LIST_LOAD);
}, []);
let content;
let content: JSX.Element;
if (loading) {
content = (
@ -231,5 +197,33 @@ export const RestoreList: React.FunctionComponent = () => {
);
}
return <section data-test-subj="restoreList">{content}</section>;
return (
<WithPrivileges privileges="index.*">
{({ hasPrivileges, privilegesMissing }) =>
hasPrivileges ? (
<section data-test-subj="restoreList">{content}</section>
) : (
<NotAuthorizedSection
title={
<FormattedMessage
id="xpack.snapshotRestore.restoreList.deniedPrivilegeTitle"
defaultMessage="You're missing index privileges"
/>
}
message={
<FormattedMessage
id="xpack.snapshotRestore.restoreList.deniedPrivilegeDescription"
defaultMessage="To view snapshot restore status, you must have {privilegesCount,
plural, one {this index privilege} other {these index privileges}} for one or more indices: {missingPrivileges}."
values={{
missingPrivileges: privilegesMissing.index!.join(', '),
privilegesCount: privilegesMissing.index!.length,
}}
/>
}
/>
)
}
</WithPrivileges>
);
};

View file

@ -1,15 +0,0 @@
/*
* 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 { API_BASE_PATH } from '../../../../common/constants';
import { httpService } from './http';
import { useRequest } from './use_request';
export const useLoadPermissions = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}permissions`),
method: 'get',
});
};

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { httpService } from './http';
export * from './app_requests';
export * from './repository_requests';
export * from './snapshot_requests';
export * from './restore_requests';

View file

@ -1,38 +0,0 @@
/*
* 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 { createContext, useContext, Dispatch, ReducerAction } from 'react';
import { AppState, AppAction } from '../../types';
type StateReducer = (state: AppState, action: AppAction) => AppState;
type ReducedStateContext = [AppState, Dispatch<ReducerAction<StateReducer>>];
export const initialState: AppState = {
permissions: {
hasPermission: true,
missingClusterPrivileges: [],
missingIndexPrivileges: [],
},
};
export const reducer: StateReducer = (state, action) => {
const { type, permissions } = action;
switch (type) {
case 'updatePermissions':
return {
...state,
permissions,
};
default:
return state;
}
};
const StateContext = createContext<ReducedStateContext>([initialState, () => {}]);
export const AppStateProvider = StateContext.Provider;
export const useAppState = () => useContext<ReducedStateContext>(StateContext);

View file

@ -3,7 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AppPermissions } from '../../../common/types';
import { AppCore, AppPlugins } from '../../shim';
export { AppCore, AppPlugins } from '../../shim';
@ -11,9 +10,3 @@ export interface AppDependencies {
core: AppCore;
plugins: AppPlugins;
}
export interface AppState {
permissions: AppPermissions;
}
export type AppAction = { type: string } & { permissions: AppState['permissions'] };

View file

@ -9,36 +9,34 @@ import {
APP_REQUIRED_CLUSTER_PRIVILEGES,
APP_RESTORE_INDEX_PRIVILEGES,
} from '../../../common/constants';
import { AppPermissions } from '../../../common/types';
// NOTE: now we import it from our "public" folder, but when the Authorisation lib
// will move to the "es_ui_shared" plugin, it will be imported from its "static" folder
import { Privileges } from '../../../public/app/lib/authorization';
import { Plugins } from '../../../shim';
let xpackMainPlugin: any;
export function registerAppRoutes(router: Router, plugins: Plugins) {
xpackMainPlugin = plugins.xpack_main;
router.get('permissions', getPermissionsHandler);
router.get('privileges', getPrivilegesHandler);
}
export function getXpackMainPlugin() {
return xpackMainPlugin;
}
const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean }): string[] => {
return Object.keys(privilegesObject).reduce(
(privileges: string[], privilegeName: string): string[] => {
if (!privilegesObject[privilegeName]) {
privileges.push(privilegeName);
}
return privileges;
},
[]
);
};
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 getPermissionsHandler: RouterRouteHandler = async (
export const getPrivilegesHandler: RouterRouteHandler = async (
req,
callWithRequest
): Promise<AppPermissions> => {
): Promise<Privileges> => {
const xpackInfo = getXpackMainPlugin() && getXpackMainPlugin().info;
if (!xpackInfo) {
// xpackInfo is updated via poll, so it may not be available until polling has begun.
@ -46,30 +44,35 @@ export const getPermissionsHandler: RouterRouteHandler = async (
throw wrapCustomError(new Error('Security info unavailable'), 503);
}
const permissionsResult: AppPermissions = {
hasPermission: true,
missingClusterPrivileges: [],
missingIndexPrivileges: [],
const privilegesResult: Privileges = {
hasAllPrivileges: true,
missingPrivileges: {
cluster: [],
index: [],
},
};
const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security');
if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) {
// If security isn't enabled, let the user use app.
return permissionsResult;
return privilegesResult;
}
// Get cluster priviliges
const { has_all_requested: hasPermission, cluster } = await callWithRequest('transport.request', {
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
cluster: APP_REQUIRED_CLUSTER_PRIVILEGES,
},
});
const { has_all_requested: hasAllPrivileges, cluster } = await callWithRequest(
'transport.request',
{
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
cluster: APP_REQUIRED_CLUSTER_PRIVILEGES,
},
}
);
// Find missing cluster privileges and set overall app permissions
permissionsResult.missingClusterPrivileges = extractMissingPrivileges(cluster || {});
permissionsResult.hasPermission = hasPermission;
// Find missing cluster privileges and set overall app privileges
privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(cluster);
privilegesResult.hasAllPrivileges = hasAllPrivileges;
// Get all index privileges the user has
const { indices } = await callWithRequest('transport.request', {
@ -92,8 +95,8 @@ export const getPermissionsHandler: RouterRouteHandler = async (
// If they don't, return list of required index privileges
if (!oneIndexWithAllPrivileges) {
permissionsResult.missingIndexPrivileges = [...APP_RESTORE_INDEX_PRIVILEGES];
privilegesResult.missingPrivileges.index = [...APP_RESTORE_INDEX_PRIVILEGES];
}
return permissionsResult;
return privilegesResult;
};

View file

@ -9868,10 +9868,6 @@
"xpack.snapshotRestore.addRepository.savingRepositoryErrorTitle": "新規レポジトリを登録できません",
"xpack.snapshotRestore.addRepositoryButtonLabel": "レポジトリを登録",
"xpack.snapshotRestore.addRepositoryTitle": "レポジトリの登録",
"xpack.snapshotRestore.app.checkingPermissionsDescription": "パーミッションを確認中…",
"xpack.snapshotRestore.app.checkingPermissionsErrorMessage": "パーミッションの確認中にエラーが発生",
"xpack.snapshotRestore.app.deniedPermissionDescription": "スナップショットレポジトリを使用するには、{clusterPrivilegesCount, plural, one {このクラスター特権} other {これらのクラスター特権}}が必要です: {clusterPrivileges}。",
"xpack.snapshotRestore.app.deniedPermissionTitle": "クラスター特権が足りません",
"xpack.snapshotRestore.appName": "スナップショットリポジドリ",
"xpack.snapshotRestore.dataPlaceholderLabel": "-",
"xpack.snapshotRestore.deleteRepository.confirmModal.cancelButtonLabel": "キャンセル",

View file

@ -9867,10 +9867,6 @@
"xpack.snapshotRestore.addRepository.savingRepositoryErrorTitle": "无法注册新存储库",
"xpack.snapshotRestore.addRepositoryButtonLabel": "注册存储库",
"xpack.snapshotRestore.addRepositoryTitle": "注册存储库",
"xpack.snapshotRestore.app.checkingPermissionsDescription": "正在检查权限......",
"xpack.snapshotRestore.app.checkingPermissionsErrorMessage": "检查权限时出错",
"xpack.snapshotRestore.app.deniedPermissionDescription": "要使用“快照存储库”,必须具有{clusterPrivilegesCount, plural, one {以下集群权限} other {以下集群权限}}{clusterPrivileges}。",
"xpack.snapshotRestore.app.deniedPermissionTitle": "您缺少集群权限",
"xpack.snapshotRestore.appName": "快照存储库",
"xpack.snapshotRestore.dataPlaceholderLabel": "-",
"xpack.snapshotRestore.deleteRepository.confirmModal.cancelButtonLabel": "取消",