diff --git a/x-pack/legacy/plugins/snapshot_restore/common/constants.ts b/x-pack/legacy/plugins/snapshot_restore/common/constants.ts index 8230893ef5d60..d876c6ffd581d 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/constants.ts @@ -48,4 +48,8 @@ export const REPOSITORY_PLUGINS_MAP: { [key: string]: RepositoryType } = { 'repository-gcs': REPOSITORY_TYPES.gcs, }; -export const APP_PERMISSIONS = ['create_snapshot', 'cluster:admin/repository']; +export const APP_REQUIRED_CLUSTER_PRIVILEGES = [ + 'cluster:admin/snapshot', + 'cluster:admin/repository', +]; +export const APP_RESTORE_INDEX_PRIVILEGES = ['monitor']; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/app.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/app.ts new file mode 100644 index 0000000000000..3a48f115439a2 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/app.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export interface AppPermissions { + hasPermission: boolean; + missingClusterPrivileges: string[]; + missingIndexPrivileges: string[]; +} diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/index.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/index.ts index b8a2cb417d403..035f1e247ad04 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/index.ts @@ -4,6 +4,7 @@ * 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'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/app.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/app.tsx index 06d7e321614d1..4212c2613366f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/app.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/app.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui'; @@ -12,6 +12,7 @@ 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'; export const App: React.FunctionComponent = () => { @@ -21,16 +22,37 @@ export const App: React.FunctionComponent = () => { }, } = 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: { hasPermission, missingClusterPrivileges } = { - hasPermission: true, - missingClusterPrivileges: [], - }, + 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 ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_list.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_list.tsx index e27e854233cf9..c120b6d9949f2 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_list.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_list.tsx @@ -20,6 +20,7 @@ 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 { RestoreTable } from './restore_table'; @@ -42,6 +43,40 @@ 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 ( + + + + } + body={ +

+ +

+ } + /> + ); + } + // State for tracking interval picker const [isIntervalMenuOpen, setIsIntervalMenuOpen] = useState(false); const [currentInterval, setCurrentInterval] = useState(INTERVAL_OPTIONS[1]); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/use_request.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/use_request.ts index e372bcbbfe178..8d88d8e06eb8a 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/use_request.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/use_request.ts @@ -103,7 +103,7 @@ export const useRequest = ({ return; } - // Only set data if we are doing polling + // Set just data if we are doing polling if (isPollRequest) { setPolling(false); if (response.data) { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/state/app_state.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/state/app_state.ts index e50e01add7867..0cfe483a0af9b 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/state/app_state.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/state/app_state.ts @@ -4,20 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createContext, useContext } from 'react'; -import { AppState } from '../../types'; +import { createContext, useContext, Dispatch, ReducerAction } from 'react'; +import { AppState, AppAction } from '../../types'; -const StateContext = createContext({}); +type StateReducer = (state: AppState, action: AppAction) => AppState; +type ReducedStateContext = [AppState, Dispatch>]; -export const initialState = {}; +export const initialState: AppState = { + permissions: { + hasPermission: true, + missingClusterPrivileges: [], + missingIndexPrivileges: [], + }, +}; -export const reducer = (state: any, action: { type: string }) => { - switch (action.type) { +export const reducer: StateReducer = (state, action) => { + const { type, permissions } = action; + switch (type) { + case 'updatePermissions': + return { + ...state, + permissions, + }; default: return state; } }; +const StateContext = createContext([initialState, () => {}]); + export const AppStateProvider = StateContext.Provider; -export const useAppState = () => useContext(StateContext); +export const useAppState = () => useContext(StateContext); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/types/app.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/types/app.ts index 28d83e3e4bb24..c4896addec259 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/types/app.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/types/app.ts @@ -3,6 +3,7 @@ * 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'; @@ -12,5 +13,7 @@ export interface AppDependencies { } export interface AppState { - [key: string]: any; + permissions: AppPermissions; } + +export type AppAction = { type: string } & { permissions: AppState['permissions'] }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/app.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/app.ts index 7a784fb5d0198..5d9ca2eb4abb5 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/app.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/app.ts @@ -5,7 +5,11 @@ */ import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router'; import { wrapCustomError } from '../../../../../server/lib/create_router/error_wrappers'; -import { APP_PERMISSIONS } from '../../../common/constants'; +import { + APP_REQUIRED_CLUSTER_PRIVILEGES, + APP_RESTORE_INDEX_PRIVILEGES, +} from '../../../common/constants'; +import { AppPermissions } from '../../../common/types'; import { Plugins } from '../../../shim'; let xpackMainPlugin: any; @@ -19,7 +23,22 @@ export function getXpackMainPlugin() { return xpackMainPlugin; } -export const getPermissionsHandler: RouterRouteHandler = async (req, callWithRequest) => { +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; + }, + [] + ); +}; + +export const getPermissionsHandler: RouterRouteHandler = async ( + req, + callWithRequest +): Promise => { const xpackInfo = getXpackMainPlugin() && getXpackMainPlugin().info; if (!xpackInfo) { // xpackInfo is updated via poll, so it may not be available until polling has begun. @@ -27,35 +46,54 @@ export const getPermissionsHandler: RouterRouteHandler = async (req, callWithReq throw wrapCustomError(new Error('Security info unavailable'), 503); } + const permissionsResult: AppPermissions = { + hasPermission: true, + missingClusterPrivileges: [], + missingIndexPrivileges: [], + }; + 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 { - hasPermission: true, - missingClusterPrivileges: [], - }; + return permissionsResult; } + // Get cluster priviliges const { has_all_requested: hasPermission, cluster } = await callWithRequest('transport.request', { path: '/_security/user/_has_privileges', method: 'POST', body: { - cluster: APP_PERMISSIONS, + cluster: APP_REQUIRED_CLUSTER_PRIVILEGES, }, }); - const missingClusterPrivileges = Object.keys(cluster).reduce( - (permissions: string[], permissionName: string): string[] => { - if (!cluster[permissionName]) { - permissions.push(permissionName); - } - return permissions; - }, - [] - ); + // Find missing cluster privileges and set overall app permissions + permissionsResult.missingClusterPrivileges = extractMissingPrivileges(cluster || {}); + permissionsResult.hasPermission = hasPermission; - return { - hasPermission, - missingClusterPrivileges, - }; + // Get all index privileges the user has + const { indices } = await callWithRequest('transport.request', { + path: '/_security/user/_privileges', + method: 'GET', + }); + + // Check if they have all the required index privileges for at least one index + const oneIndexWithAllPrivileges = indices.find(({ privileges }: { privileges: string[] }) => { + if (privileges.includes('all')) { + return true; + } + + const indexHasAllPrivileges = APP_RESTORE_INDEX_PRIVILEGES.every(privilege => + privileges.includes(privilege) + ); + + return indexHasAllPrivileges; + }); + + // If they don't, return list of required index privileges + if (!oneIndexWithAllPrivileges) { + permissionsResult.missingIndexPrivileges = [...APP_RESTORE_INDEX_PRIVILEGES]; + } + + return permissionsResult; };