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:
Kurt 2022-11-14 11:30:37 -05:00 committed by GitHub
parent 9e28b7cedc
commit 90f6ffb353
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 260 additions and 63 deletions

View file

@ -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"

View file

@ -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);
});
});
});

View file

@ -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."
/>
);
}
}
}

View file

@ -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>
`);

View file

@ -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>

View file

@ -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: [],
},
],