mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Adding readonly view for API Keys page (#144923)
## Summary Adding a `readonly` view for users with `read_security` cluster privileges ## Release Note The API Keys screen can be accessed in a Read Only view with the the cluster privilege `read_security` ## Testing Steps Login as `elastic` and create a `role` with the `read_security` cluster privilege <img width="877" alt="Screen Shot 2022-11-09 at 1 03 05 PM" src="https://user-images.githubusercontent.com/21210601/200908865-b11ffe67-106e-45c4-a704-9120b5cc4a38.png"> Create a test user and assign the newly create role, as well as `viewer` and `kibana_admin` <img width="860" alt="Screen Shot 2022-11-09 at 1 03 48 PM" src="https://user-images.githubusercontent.com/21210601/200909077-710efb9d-4863-4a56-a3c1-65fc979d16b6.png"> Login as the new test user and navigate to Stack Management > API Keys Verify there aren't any Create buttons and that the ReadOnly `glasses` icon is in the top right <img width="1311" alt="Screen Shot 2022-11-09 at 1 04 59 PM" src="https://user-images.githubusercontent.com/21210601/200909224-e291f3cf-39ee-4629-ab75-f355ced80db1.png"> Login as `elastic` and create an API key, remember the name of the key Go to Dev Tools and use the following script to grant usage of the API key to the test user, use the following block: ```json POST /_security/api_key/grant { "grant_type": "password", "username" : "elastic", "password" : "changeme", "run_as": "test_user", "api_key" : { "name": "test-api-key" } } ``` Login as the test user and navigate to the API Keys page, notice the granted API key is displayed, but you are unable to `delete` or `create` new keys <img width="1058" alt="Screen Shot 2022-11-09 at 1 06 48 PM" src="https://user-images.githubusercontent.com/21210601/200909524-e0a3cc20-9626-4d39-8277-d77c3d795ee0.png"> Co-authored-by: Thomas Watson <w@tson.dk>
This commit is contained in:
parent
9e28b7cedc
commit
90f6ffb353
6 changed files with 260 additions and 63 deletions
|
@ -17,10 +17,12 @@ import { useHtmlId } from '../../../components/use_html_id';
|
|||
|
||||
export interface ApiKeysEmptyPromptProps {
|
||||
error?: Error;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ApiKeysEmptyPrompt: FunctionComponent<ApiKeysEmptyPromptProps> = ({
|
||||
error,
|
||||
readOnly,
|
||||
children,
|
||||
}) => {
|
||||
const accordionId = useHtmlId('apiKeysEmptyPrompt', 'accordion');
|
||||
|
@ -115,6 +117,30 @@ export const ApiKeysEmptyPrompt: FunctionComponent<ApiKeysEmptyPromptProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<KibanaPageTemplate.EmptyPrompt
|
||||
iconType="crossInACircleFilled"
|
||||
title={
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.readOnlyEmptyTitle"
|
||||
defaultMessage="You do not have permission to create API keys"
|
||||
/>
|
||||
</h1>
|
||||
}
|
||||
body={
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.readOnlyEmptyMessage"
|
||||
defaultMessage="Please contact your administrator for more information"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KibanaPageTemplate.EmptyPrompt
|
||||
iconType="gear"
|
||||
|
|
|
@ -33,12 +33,13 @@ describe('APIKeysGridPage', () => {
|
|||
// since we are using EuiErrorBoundary and react will console.error any errors
|
||||
const consoleWarnMock = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
const coreStart = coreMock.createStart();
|
||||
let coreStart: ReturnType<typeof coreMock.createStart>;
|
||||
const theme$ = themeServiceMock.createTheme$();
|
||||
const apiClientMock = apiKeysAPIClientMock.create();
|
||||
const { authc } = securityMock.createSetup();
|
||||
|
||||
beforeEach(() => {
|
||||
coreStart = coreMock.createStart();
|
||||
apiClientMock.checkPrivileges.mockClear();
|
||||
apiClientMock.getApiKeys.mockClear();
|
||||
coreStart.http.get.mockClear();
|
||||
|
@ -50,6 +51,7 @@ describe('APIKeysGridPage', () => {
|
|||
canManage: true,
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
apiClientMock.getApiKeys.mockResolvedValue({
|
||||
apiKeys: [
|
||||
{
|
||||
|
@ -83,19 +85,29 @@ describe('APIKeysGridPage', () => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('loads and displays API keys', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
|
||||
const { findByText } = render(
|
||||
coreStart.application.capabilities = {
|
||||
...coreStart.application.capabilities,
|
||||
api_keys: {
|
||||
save: true,
|
||||
},
|
||||
};
|
||||
|
||||
const { findByText, queryByTestId } = render(
|
||||
<Providers services={coreStart} theme$={theme$} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
readOnly={false}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
expect(await queryByTestId('apiKeysCreateTableButton')).toBeNull();
|
||||
expect(await findByText(/Loading API keys/)).not.toBeInTheDocument();
|
||||
await findByText(/first-api-key/);
|
||||
await findByText(/second-api-key/);
|
||||
|
@ -114,12 +126,20 @@ describe('APIKeysGridPage', () => {
|
|||
isAdmin: true,
|
||||
});
|
||||
|
||||
coreStart.application.capabilities = {
|
||||
...coreStart.application.capabilities,
|
||||
api_keys: {
|
||||
save: true,
|
||||
},
|
||||
};
|
||||
|
||||
const { findByText } = render(
|
||||
<Providers services={coreStart} theme$={theme$} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
readOnly={false}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
@ -136,12 +156,20 @@ describe('APIKeysGridPage', () => {
|
|||
isAdmin: false,
|
||||
});
|
||||
|
||||
coreStart.application.capabilities = {
|
||||
...coreStart.application.capabilities,
|
||||
api_keys: {
|
||||
save: true,
|
||||
},
|
||||
};
|
||||
|
||||
const { findByText } = render(
|
||||
<Providers services={coreStart} theme$={theme$} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
readOnly={false}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
@ -160,12 +188,20 @@ describe('APIKeysGridPage', () => {
|
|||
});
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
|
||||
coreStart.application.capabilities = {
|
||||
...coreStart.application.capabilities,
|
||||
api_keys: {
|
||||
save: true,
|
||||
},
|
||||
};
|
||||
|
||||
const { findByText } = render(
|
||||
<Providers services={coreStart} theme$={theme$} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
readOnly={false}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
@ -173,4 +209,75 @@ describe('APIKeysGridPage', () => {
|
|||
expect(await findByText(/Loading API keys/)).not.toBeInTheDocument();
|
||||
await findByText(/Could not load API keys/);
|
||||
});
|
||||
|
||||
describe('Read Only View', () => {
|
||||
beforeEach(() => {
|
||||
apiClientMock.checkPrivileges.mockResolvedValueOnce({
|
||||
areApiKeysEnabled: true,
|
||||
canManage: false,
|
||||
isAdmin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display prompt `Create Button` when no API keys are shown', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
|
||||
apiClientMock.getApiKeys.mockResolvedValue({
|
||||
apiKeys: [],
|
||||
});
|
||||
|
||||
coreStart.application.capabilities = {
|
||||
...coreStart.application.capabilities,
|
||||
api_keys: {
|
||||
save: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { findByText, queryByText } = render(
|
||||
<Providers services={coreStart} theme$={theme$} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
readOnly={true}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
expect(await findByText(/Loading API keys/)).not.toBeInTheDocument();
|
||||
expect(await findByText('You do not have permission to create API keys')).toBeInTheDocument();
|
||||
expect(queryByText('Create API key')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not display table `Create Button` nor `Delete` icons column', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
|
||||
coreStart.application.capabilities = {
|
||||
...coreStart.application.capabilities,
|
||||
api_keys: {
|
||||
save: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { findByText, queryByText, queryAllByText } = await render(
|
||||
<Providers services={coreStart} theme$={theme$} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
readOnly={true}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
expect(await findByText(/Loading API keys/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
await findByText('You only have permission to view your own API keys.')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await findByText('View your API keys. An API key sends requests on your behalf.')
|
||||
).toBeInTheDocument();
|
||||
expect(queryByText('Create API key')).toBeNull();
|
||||
expect(queryAllByText('Delete').length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -48,6 +48,7 @@ interface Props {
|
|||
history: History;
|
||||
notifications: NotificationsStart;
|
||||
apiKeysAPIClient: PublicMethodsOf<APIKeysAPIClient>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
@ -65,6 +66,10 @@ interface State {
|
|||
const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss';
|
||||
|
||||
export class APIKeysGridPage extends Component<Props, State> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = {
|
||||
|
@ -147,25 +152,31 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
}
|
||||
|
||||
if (!isLoadingTable && apiKeys && apiKeys.length === 0) {
|
||||
return (
|
||||
<ApiKeysEmptyPrompt>
|
||||
<EuiButton
|
||||
{...reactRouterNavigate(this.props.history, '/create')}
|
||||
fill
|
||||
iconType="plusInCircleFilled"
|
||||
data-test-subj="apiKeysCreatePromptButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.createButton"
|
||||
defaultMessage="Create API key"
|
||||
/>
|
||||
</EuiButton>
|
||||
</ApiKeysEmptyPrompt>
|
||||
);
|
||||
if (this.props.readOnly) {
|
||||
return <ApiKeysEmptyPrompt readOnly={this.props.readOnly} />;
|
||||
} else {
|
||||
return (
|
||||
<ApiKeysEmptyPrompt>
|
||||
<EuiButton
|
||||
{...reactRouterNavigate(this.props.history, '/create')}
|
||||
fill
|
||||
iconType="plusInCircleFilled"
|
||||
data-test-subj="apiKeysCreatePromptButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.createButton"
|
||||
defaultMessage="Create API key"
|
||||
/>
|
||||
</EuiButton>
|
||||
</ApiKeysEmptyPrompt>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const concatenated = `${this.state.createdApiKey?.id}:${this.state.createdApiKey?.api_key}`;
|
||||
|
||||
const description = this.determineDescription(isAdmin, this.props.readOnly ?? false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<KibanaPageTemplate.Header
|
||||
|
@ -177,34 +188,24 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
defaultMessage="API Keys"
|
||||
/>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
{isAdmin ? (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysAllDescription"
|
||||
defaultMessage="View and delete API keys. An API key sends requests on behalf of a user."
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysOwnDescription"
|
||||
defaultMessage="View and delete your API keys. An API key sends requests on your behalf."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
description={description}
|
||||
rightSideItems={
|
||||
this.props.readOnly
|
||||
? undefined
|
||||
: [
|
||||
<EuiButton
|
||||
{...reactRouterNavigate(this.props.history, '/create')}
|
||||
fill
|
||||
iconType="plusInCircleFilled"
|
||||
data-test-subj="apiKeysCreateTableButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.createButton"
|
||||
defaultMessage="Create API key"
|
||||
/>
|
||||
</EuiButton>,
|
||||
]
|
||||
}
|
||||
rightSideItems={[
|
||||
<EuiButton
|
||||
{...reactRouterNavigate(this.props.history, '/create')}
|
||||
fill
|
||||
iconType="plusInCircleFilled"
|
||||
data-test-subj="apiKeysCreateTableButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.createButton"
|
||||
defaultMessage="Create API key"
|
||||
/>
|
||||
</EuiButton>,
|
||||
]}
|
||||
/>
|
||||
|
||||
{this.state.createdApiKey && !this.state.isLoadingTable && (
|
||||
|
@ -421,20 +422,13 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
: undefined,
|
||||
};
|
||||
|
||||
const callOutTitle = this.determineCallOutTitle(this.props.readOnly ?? false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isAdmin ? (
|
||||
<>
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.manageOwnKeysWarning"
|
||||
defaultMessage="You only have permission to manage your own API keys."
|
||||
/>
|
||||
}
|
||||
color="primary"
|
||||
iconType="user"
|
||||
/>
|
||||
<EuiCallOut title={callOutTitle} color="primary" iconType="user" />
|
||||
<EuiSpacer />
|
||||
</>
|
||||
) : undefined}
|
||||
|
@ -451,7 +445,7 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
columns={this.getColumnConfig(invalidateApiKeyPrompt)}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
selection={selection}
|
||||
selection={this.props.readOnly ? undefined : selection}
|
||||
pagination={pagination}
|
||||
loading={isLoadingTable}
|
||||
error={
|
||||
|
@ -580,7 +574,10 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
]);
|
||||
|
||||
if (!this.props.readOnly) {
|
||||
config.push({
|
||||
actions: [
|
||||
{
|
||||
name: i18n.translate('xpack.security.management.apiKeys.table.deleteAction', {
|
||||
|
@ -600,8 +597,8 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
'data-test-subj': 'apiKeysTableDeleteAction',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
@ -618,7 +615,7 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
await this.props.apiKeysAPIClient.checkPrivileges();
|
||||
this.setState({ isAdmin, canManage, areApiKeysEnabled });
|
||||
|
||||
if (!canManage || !areApiKeysEnabled) {
|
||||
if ((!canManage && !this.props.readOnly) || !areApiKeysEnabled) {
|
||||
this.setState({ isLoadingApp: false });
|
||||
} else {
|
||||
this.loadApiKeys();
|
||||
|
@ -654,4 +651,47 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
|
||||
this.setState({ isLoadingApp: false, isLoadingTable: false });
|
||||
};
|
||||
|
||||
private determineDescription(isAdmin: boolean, readOnly: boolean) {
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysAllDescription"
|
||||
defaultMessage="View and delete API keys. An API key sends requests on behalf of a user."
|
||||
/>
|
||||
);
|
||||
} else if (readOnly) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysReadOnlyDescription"
|
||||
defaultMessage="View your API keys. An API key sends requests on your behalf."
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysOwnDescription"
|
||||
defaultMessage="View and delete your API keys. An API key sends requests on your behalf."
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private determineCallOutTitle(readOnly: boolean) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.readOnlyOwnKeysWarning"
|
||||
defaultMessage="You only have permission to view your own API keys."
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.manageOwnKeysWarning"
|
||||
defaultMessage="You only have permission to manage your own API keys."
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,16 +34,27 @@ describe('apiKeysManagementApp', () => {
|
|||
});
|
||||
|
||||
it('mount() works for the `grid` page', async () => {
|
||||
const { getStartServices } = coreMock.createSetup();
|
||||
const coreStart = coreMock.createSetup();
|
||||
const { authc } = securityMock.createSetup();
|
||||
|
||||
const startServices = await getStartServices();
|
||||
const startServices = await coreStart.getStartServices();
|
||||
|
||||
const [{ application }] = startServices;
|
||||
application.capabilities = {
|
||||
...application.capabilities,
|
||||
api_keys: {
|
||||
save: true,
|
||||
},
|
||||
};
|
||||
|
||||
const docTitle = startServices[0].chrome.docTitle;
|
||||
|
||||
const container = document.createElement('div');
|
||||
|
||||
const setBreadcrumbs = jest.fn();
|
||||
|
||||
let unmount: Unmount;
|
||||
|
||||
await act(async () => {
|
||||
unmount = await apiKeysManagementApp
|
||||
.create({ authc, getStartServices: () => Promise.resolve(startServices) as any })
|
||||
|
@ -84,7 +95,8 @@ describe('apiKeysManagementApp', () => {
|
|||
"anonymousPaths": {},
|
||||
"externalUrl": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"readOnly": false
|
||||
}
|
||||
</div>
|
||||
`);
|
||||
|
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from '../../components/breadcrumb';
|
||||
import { AuthenticationProvider } from '../../components/use_current_user';
|
||||
import type { PluginStartDependencies } from '../../plugin';
|
||||
import { ReadonlyBadge } from '../badges/readonly_badge';
|
||||
|
||||
interface CreateParams {
|
||||
authc: AuthenticationServiceSetup;
|
||||
|
@ -67,6 +68,7 @@ export const apiKeysManagementApp = Object.freeze({
|
|||
history={history}
|
||||
notifications={coreStart.notifications}
|
||||
apiKeysAPIClient={new APIKeysAPIClient(coreStart.http)}
|
||||
readOnly={!coreStart.application.capabilities.api_keys.save}
|
||||
/>
|
||||
</Breadcrumb>
|
||||
</Providers>,
|
||||
|
@ -102,6 +104,12 @@ export const Providers: FunctionComponent<ProvidersProps> = ({
|
|||
<I18nProvider>
|
||||
<KibanaThemeProvider theme$={theme$}>
|
||||
<Router history={history}>
|
||||
<ReadonlyBadge
|
||||
featureId="api_keys"
|
||||
tooltip={i18n.translate('xpack.security.management.api_keys.readonlyTooltip', {
|
||||
defaultMessage: 'Unable to create or edit API keys',
|
||||
})}
|
||||
/>
|
||||
<BreadcrumbsProvider onChange={onChange}>{children}</BreadcrumbsProvider>
|
||||
</Router>
|
||||
</KibanaThemeProvider>
|
||||
|
|
|
@ -52,10 +52,14 @@ const apiKeysManagementFeature: ElasticsearchFeatureConfig = {
|
|||
privileges: [
|
||||
{
|
||||
requiredClusterPrivileges: ['manage_api_key'],
|
||||
ui: [],
|
||||
ui: ['save'],
|
||||
},
|
||||
{
|
||||
requiredClusterPrivileges: ['manage_own_api_key'],
|
||||
ui: ['save'],
|
||||
},
|
||||
{
|
||||
requiredClusterPrivileges: ['read_security'],
|
||||
ui: [],
|
||||
},
|
||||
],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue