[RBAC] - minor UI bug fixes (#1185)

# What this PR does

- remove hardcoded references to "Admin" and "Editor". Instead, use the
`determineRequiredAuthString` and `generateMissingPermissionMessage`
methods from `utils/authorization` which conditionally show the
permission or role depending on whether RBAC is enabled or not
- fix bug on list users page that always showed "Loading...".
![Screenshot 2023-01-21 at 09 29
35](https://user-images.githubusercontent.com/9406895/213859785-e9852838-5671-4275-aaed-4df75446ab6a.png)
- only show users the user's table if they are allowed, otherwise show
them this
  - RBAC enabled
![Screenshot 2023-01-26 at 09 09
57](https://user-images.githubusercontent.com/9406895/214786723-3389ce9c-7353-4216-9176-6547f2076660.png)
  - RBAC disabled
![Screenshot 2023-01-26 at 09 05
30](https://user-images.githubusercontent.com/9406895/214786739-eb9ee108-e79c-4105-912a-8bb5bf03cb32.png)
- `Schedules Editor` role minor issue
- Viewers are not allowed to list users by default, so granting schedule
edit permission to them won’t allow them to see who rotations are
assigned to or assign a rotation to a user. To make this a more
straightforward user experience, instead of saying "No options found",
we will tell the user that they are missing a particular role/permission
to view these users:
    Before:
![Screenshot 2023-01-23 at 09 52
28](https://user-images.githubusercontent.com/9406895/213999896-d889540e-2835-4133-965a-306923a3e33b.png)
    After:
![Screenshot 2023-01-26 at 12 39
30](https://user-images.githubusercontent.com/9406895/214827179-d127002f-793e-4ab7-ad75-dfab653b7d7d.png)


## Which issue(s) this PR fixes

- closes #971 

## Checklist

- [x] Tests updated
- [ ] Documentation added (N/A)
- [x] `CHANGELOG.md` updated
This commit is contained in:
Joey Orlando 2023-01-30 11:23:02 +01:00 committed by GitHub
parent a47fc7048e
commit 63e91f896b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 152 additions and 84 deletions

View file

@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fix bugs related to creating contact point for Grafana Alerting integration
- Fix minor UI bug on OnCall users page where it would idefinitely show a "Loading..." message
- Only show OnCall user's table to users that are authorized
- Fixed NPE in ScheduleUserDetails component ([#1229](https://github.com/grafana/oncall/issues/1229))
## v1.1.19 (2023-01-25)

View file

@ -8,6 +8,7 @@ import { SortableContainer, SortableElement, SortableHandle } from 'react-sortab
import Text from 'components/Text/Text';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
import { User } from 'models/user/user.types';
import { UserActions } from 'utils/authorization';
import { fromPlainArray, toPlainArray } from './UserGroups.helpers';
import { Item } from './UserGroups.types';
@ -114,6 +115,7 @@ const UserGroups = (props: UserGroupsProps) => {
onChange={handleUserAdd}
showError={showError}
maxMenuHeight={150}
requiredUserAction={UserActions.UserSettingsWrite}
/>
<SortableList
renderItem={renderItem}

View file

@ -12,7 +12,7 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
import { ApiToken } from 'models/api_token/api_token.types';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
import ApiTokenForm from './ApiTokenForm';
@ -21,6 +21,7 @@ import styles from './ApiTokenSettings.module.css';
const cx = cn.bind(styles);
const MAX_TOKENS_PER_USER = 5;
const REQUIRED_PERMISSION_TO_VIEW = UserActions.APIKeysWrite;
interface ApiTokensProps extends WithStoreProps {}
@ -67,6 +68,15 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
},
];
const authorizedToViewAPIKeys = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW);
let emptyText = 'Loading...';
if (!authorizedToViewAPIKeys) {
emptyText = `${generateMissingPermissionMessage(REQUIRED_PERMISSION_TO_VIEW)} to be able to view API tokens.`;
} else if (apiTokens) {
emptyText = 'No tokens found';
}
return (
<>
<GTable
@ -92,13 +102,7 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
className="api-keys"
showHeader={!isMobile}
data={apiTokens}
emptyText={
isUserActionAllowed(UserActions.APIKeysWrite)
? apiTokens
? 'No tokens found'
: 'Loading...'
: 'API tokens are available only for users with Admin permissions'
}
emptyText={emptyText}
columns={columns}
/>
{showCreateTokenModal && (

View file

@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { AsyncMultiSelect, AsyncSelect } from '@grafana/ui';
import { inject, observer } from 'mobx-react';
import { makeRequest } from 'network';
import { makeRequest, isNetworkError } from 'network';
import { UserAction, generateMissingPermissionMessage } from 'utils/authorization';
interface RemoteSelectProps {
autoFocus?: boolean;
@ -24,6 +25,7 @@ interface RemoteSelectProps {
getOptionLabel?: (item: SelectableValue) => React.ReactNode;
showError?: boolean;
maxMenuHeight?: number;
requiredUserAction?: UserAction;
}
const RemoteSelect = inject('store')(
@ -45,9 +47,12 @@ const RemoteSelect = inject('store')(
openMenuOnFocus = true,
showError,
maxMenuHeight,
requiredUserAction,
} = props;
const getOptions = (data: any[]) => {
const [noOptionsMessage, setNoOptionsMessage] = useState<string>('No options found');
const getOptions = (data: any[]): SelectableValue[] => {
return data.map((option: any) => ({
value: option[valueField],
label: option[fieldToShow],
@ -62,17 +67,23 @@ const RemoteSelect = inject('store')(
const [options, setOptions] = useReducer(mergeOptions, []);
useEffect(() => {
makeRequest(href, {}).then((data) => {
setOptions(getOptions(data.results || data));
});
const loadOptionsCallback = useCallback(async (query?: string): Promise<SelectableValue[]> => {
try {
const data = await makeRequest(href, { params: { search: query } });
const options = getOptions(data.results || data);
setOptions(options);
return options;
} catch (e) {
if (isNetworkError(e) && e.response.status === 403 && requiredUserAction) {
setNoOptionsMessage(generateMissingPermissionMessage(requiredUserAction));
}
return [];
}
}, []);
const loadOptionsCallback = useCallback((query: string) => {
return makeRequest(href, { params: { search: query } }).then((data) => {
setOptions(getOptions(data.results || data));
return getOptions(data.results || data);
});
useEffect(() => {
loadOptionsCallback();
}, []);
const onChangeCallback = useCallback(
@ -119,6 +130,7 @@ const RemoteSelect = inject('store')(
defaultOptions={options}
loadOptions={loadOptionsCallback}
getOptionLabel={getOptionLabel}
noOptionsMessage={noOptionsMessage}
invalid={showError}
/>
);

View file

@ -133,7 +133,6 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
) : (
<PhoneVerification userPk={id} />
))}
{/* TODO: we should probably hide this tab when a user (ie. Admin) is viewing the user settings for another user. Would it make sense for an Admin to be able to link their mobile app to another user's profile */}
{activeTab === UserSettingsTab.MobileAppConnection && <MobileAppConnection userPk={id} />}
{activeTab === UserSettingsTab.SlackInfo && <SlackTab />}
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}

View file

@ -34,6 +34,8 @@ interface RequestConfig {
validateStatus?: (status: number) => boolean;
}
export const isNetworkError = axios.isAxiosError;
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
const { method = 'GET', params, data, validateStatus } = config;

View file

@ -127,7 +127,6 @@ export const pages: { [id: string]: PageDefinition } = [
icon: 'table',
id: 'live-settings',
text: 'Env Variables',
role: 'Admin',
hideFromTabsFn: (store: RootBaseStore) => {
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
return isTopNavbar() || !hasLiveSettings;
@ -139,7 +138,6 @@ export const pages: { [id: string]: PageDefinition } = [
icon: 'cloud',
id: 'cloud',
text: 'Cloud',
role: 'Admin',
hideFromTabsFn: (store: RootBaseStore) => {
const hasCloudFeature = store.hasFeature(AppFeature.CloudConnection);
return isTopNavbar() || !hasCloudFeature;

View file

@ -16,6 +16,7 @@ import { WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { openErrorNotification } from 'utils';
import { determineRequiredAuthString, UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import styles from './CloudPage.module.css';
@ -261,10 +262,9 @@ const CloudPage = observer((props: CloudPageProps) => {
<div style={{ width: '100%' }}>
<Text type="secondary">
{/* TODO: should probably update this message? */}
{
'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Only users with Admin or Editor role will be synced.'
}
{`Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Users must have ${determineRequiredAuthString(
UserActions.NotificationsRead
)} in order to be synced.`}
</Text>
<GTable
@ -280,15 +280,9 @@ const CloudPage = observer((props: CloudPageProps) => {
{matched_users_count === 1 ? '' : 's'}
{` matched between OSS and Cloud OnCall`}
</Text>
{syncingUsers ? (
<Button variant="primary" onClick={syncUsers} icon="sync" disabled>
Syncing...
</Button>
) : (
<Button variant="primary" onClick={syncUsers} icon="sync">
Sync users (Editors and Admins)
</Button>
)}
<Button variant="primary" onClick={syncUsers} icon="sync" disabled={syncingUsers}>
{syncingUsers ? 'Syncing...' : 'Sync users'}
</Button>
</HorizontalGroup>
</div>
)}

View file

@ -23,7 +23,7 @@ import { User as UserType } from 'models/user/user.types';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { getUserRowClassNameFn } from './Users.helpers';
@ -35,6 +35,7 @@ const cx = cn.bind(styles);
interface UsersProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
const ITEMS_PER_PAGE = 100;
const REQUIRED_PERMISSION_TO_VIEW_USERS = UserActions.UserSettingsWrite;
interface UsersState extends PageBaseState {
page: number;
@ -43,6 +44,7 @@ interface UsersState extends PageBaseState {
usersFilters?: {
searchTerm: string;
};
initialUsersLoaded: boolean;
}
@observer
@ -56,10 +58,9 @@ class Users extends React.Component<UsersProps, UsersState> {
},
errorData: initErrorDataState(),
initialUsersLoaded: false,
};
initialUsersLoaded = false;
async componentDidMount() {
const {
query: { p },
@ -74,18 +75,19 @@ class Users extends React.Component<UsersProps, UsersState> {
const { usersFilters, page } = this.state;
const { userStore } = store;
if (!isUserActionAllowed(UserActions.UserSettingsWrite)) {
if (!isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS)) {
return;
}
LocationHelper.update({ p: page }, 'partial');
return await userStore.updateItems(usersFilters, page);
await userStore.updateItems(usersFilters, page);
this.setState({ initialUsersLoaded: true });
};
componentDidUpdate(prevProps: UsersProps) {
if (!this.initialUsersLoaded && isUserActionAllowed(UserActions.UserSettingsWrite)) {
if (!this.state.initialUsersLoaded) {
this.updateUsers();
this.initialUsersLoaded = true;
}
if (prevProps.match.params.id !== this.props.match.params.id) {
@ -117,7 +119,7 @@ class Users extends React.Component<UsersProps, UsersState> {
};
render() {
const { usersFilters, userPkToEdit, page, errorData } = this.state;
const { usersFilters, userPkToEdit, page, errorData, initialUsersLoaded } = this.state;
const {
store,
match: {
@ -165,6 +167,8 @@ class Users extends React.Component<UsersProps, UsersState> {
const { count, results } = userStore.getSearchResult();
const authorizedToViewUsers = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS);
return (
<PageErrorHandlingWrapper
errorData={errorData}
@ -182,10 +186,12 @@ class Users extends React.Component<UsersProps, UsersState> {
<LegacyNavHeading>
<Text.Title level={3}>Users</Text.Title>
</LegacyNavHeading>
<Text type="secondary">
To manage permissions or add users, please visit{' '}
<a href="/org/users">Grafana user management</a>
</Text>
{authorizedToViewUsers && (
<Text type="secondary">
To manage permissions or add users, please visit{' '}
<a href="/org/users">Grafana user management</a>
</Text>
)}
</div>
</div>
<PluginLink query={{ page: 'users', id: 'me' }}>
@ -194,7 +200,7 @@ class Users extends React.Component<UsersProps, UsersState> {
</Button>
</PluginLink>
</div>
{isUserActionAllowed(UserActions.UserSettingsRead) ? (
{authorizedToViewUsers ? (
<>
<div className={cx('user-filters-container')}>
<UsersFilters
@ -213,7 +219,7 @@ class Users extends React.Component<UsersProps, UsersState> {
</div>
<GTable
emptyText={results ? 'No users found' : 'Loading...'}
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
rowKey="pk"
data={results}
columns={columns}
@ -230,8 +236,9 @@ class Users extends React.Component<UsersProps, UsersState> {
/* @ts-ignore */
title={
<>
You don't have enough permissions to view other users because you are not Admin.{' '}
<PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your profile
{generateMissingPermissionMessage(REQUIRED_PERMISSION_TO_VIEW_USERS)} to be able to view OnCall
users. <PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your
profile
</>
}
severity="info"

View file

@ -535,7 +535,7 @@
{
"role": {
"name": "User Settings Reader",
"description": "Read-only access to OnCall User Settings",
"description": "Read-only access to own OnCall User Settings",
"permissions": [
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
{ "action": "grafana-oncall-app.user-settings:read" }
@ -546,7 +546,7 @@
{
"role": {
"name": "User Settings Editor",
"description": "Read/write access to own OnCall User Settings",
"description": "Read/write access to own OnCall User Settings + ability to view basic information about other OnCall users",
"permissions": [
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
{ "action": "grafana-oncall-app.user-settings:read" },

View file

@ -34,19 +34,19 @@ exports[`PluginState.generateUnknownErrorMsg it returns the proper error message
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: false 1`] = `
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: false 1`] = `
"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)?
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: true 1`] = `"ohhhh nooo an error"`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: true 1`] = `"ohhhh nooo an error"`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 409 1`] = `
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 409 1`] = `
"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)?
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 502 1`] = `
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 502 1`] = `
"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI).
Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance."
`;

View file

@ -1,8 +1,7 @@
import { getBackendSrv } from '@grafana/runtime';
import axios from 'axios';
import { OnCallAppPluginMeta, OnCallPluginMetaJSONData, OnCallPluginMetaSecureJSONData } from 'types';
import { makeRequest } from 'network';
import { makeRequest, isNetworkError } from 'network';
import FaroHelper from 'utils/faro';
export type UpdateGrafanaPluginSettingsProps = {
@ -76,7 +75,7 @@ class PluginState {
);
const consoleMsg = `occured while trying to ${installationVerb} the plugin w/ the OnCall backend`;
if (axios.isAxiosError(e)) {
if (isNetworkError(e)) {
const { status: statusCode } = e.response;
console.warn(`An HTTP related error ${consoleMsg}`, e.response);
@ -100,7 +99,7 @@ class PluginState {
errorMsg = unknownErrorMsg;
}
} else {
// a non-axios related error occured.. this scenario shouldn't occur...
// a non-network related error occured.. this scenario shouldn't occur...
console.warn(`An unknown error ${consoleMsg}`, e);
errorMsg = unknownErrorMsg;
}
@ -115,12 +114,12 @@ class PluginState {
): string => {
let errorMsg: string;
if (axios.isAxiosError(e)) {
if (isNetworkError(e)) {
// The user likely put in a bogus URL for the OnCall API URL
console.warn('An HTTP related error occured while trying to provision the plugin w/ Grafana', e.response);
errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar);
} else {
// a non-axios related error occured.. this scenario shouldn't occur...
// a non-network related error occured.. this scenario shouldn't occur...
console.warn('An unknown error occured while trying to provision the plugin w/ Grafana', e);
errorMsg = this.generateUnknownErrorMsg(onCallApiUrl, installationVerb, onCallApiUrlIsConfiguredThroughEnvVar);
}

View file

@ -1,8 +1,9 @@
import { makeRequest as makeRequestOriginal } from 'network';
import { makeRequest as makeRequestOriginal, isNetworkError as isNetworkErrorOriginal } from 'network';
import PluginState, { InstallationVerb, PluginSyncStatusResponse, UpdateGrafanaPluginSettingsProps } from './';
const makeRequest = makeRequestOriginal as jest.Mock<ReturnType<typeof makeRequestOriginal>>;
const isNetworkError = isNetworkErrorOriginal as unknown as jest.Mock<ReturnType<typeof isNetworkErrorOriginal>>;
jest.mock('network');
@ -13,7 +14,7 @@ afterEach(() => {
const ONCALL_BASE_URL = '/plugin';
const GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings';
const generateMockAxiosError = (status: number, data = {}) => ({ isAxiosError: true, response: { status, ...data } });
const generateMockNetworkError = (status: number, data = {}) => ({ response: { status, ...data } });
describe('PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg', () => {
test.each([true, false])(
@ -54,10 +55,12 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
console.warn = () => {};
});
test.each([502, 409])('it handles a non-400 AxiosError properly - status code: %s', (status) => {
test.each([502, 409])('it handles a non-400 network error properly - status code: %s', (status) => {
isNetworkError.mockReturnValueOnce(true);
expect(
PluginState.getHumanReadableErrorFromOnCallError(
generateMockAxiosError(status),
generateMockNetworkError(status),
'http://hello.com',
'install',
true
@ -66,19 +69,23 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
});
test.each([true, false])(
'it handles a 400 AxiosError properly - has custom error message: %s',
'it handles a 400 network error properly - has custom error message: %s',
(hasCustomErrorMessage) => {
const axiosError = generateMockAxiosError(400) as any;
isNetworkError.mockReturnValueOnce(true);
const networkError = generateMockNetworkError(400) as any;
if (hasCustomErrorMessage) {
axiosError.response.data = { error: 'ohhhh nooo an error' };
networkError.response.data = { error: 'ohhhh nooo an error' };
}
expect(
PluginState.getHumanReadableErrorFromOnCallError(axiosError, 'http://hello.com', 'install', true)
PluginState.getHumanReadableErrorFromOnCallError(networkError, 'http://hello.com', 'install', true)
).toMatchSnapshot();
}
);
test('it handles an unknown error properly', () => {
isNetworkError.mockReturnValueOnce(false);
expect(
PluginState.getHumanReadableErrorFromOnCallError(new Error('asdfasdf'), 'http://hello.com', 'install', true)
).toMatchSnapshot();
@ -86,23 +93,27 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
});
describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () => {
test.each([true, false])('it handles an error properly', (isAxiosError) => {
beforeEach(() => {
console.warn = () => {};
});
test.each([true, false])('it handles an error properly - network error: %s', (networkError) => {
const onCallApiUrl = 'http://hello.com';
const installationVerb = 'install';
const onCallApiUrlIsConfiguredThroughEnvVar = true;
const axiosError = generateMockAxiosError(400);
const nonAxiosError = new Error('oh noooo');
const error = isAxiosError ? axiosError : nonAxiosError;
const error = networkError ? generateMockNetworkError(400) : new Error('oh noooo');
const mockGenerateInvalidOnCallApiURLErrorMsgResult = 'asdadslkjfkjlsd';
const mockGenerateUnknownErrorMsgResult = 'asdadslkjfkjlsd';
isNetworkError.mockReturnValueOnce(networkError);
PluginState.generateInvalidOnCallApiURLErrorMsg = jest
.fn()
.mockReturnValueOnce(mockGenerateInvalidOnCallApiURLErrorMsgResult);
PluginState.generateUnknownErrorMsg = jest.fn().mockReturnValueOnce(mockGenerateUnknownErrorMsgResult);
const expectedErrorMsg = isAxiosError
const expectedErrorMsg = networkError
? mockGenerateInvalidOnCallApiURLErrorMsgResult
: mockGenerateUnknownErrorMsgResult;
@ -115,7 +126,7 @@ describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () =>
)
).toEqual(expectedErrorMsg);
if (isAxiosError) {
if (networkError) {
expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledTimes(1);
expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledWith(
onCallApiUrl,

View file

@ -63,6 +63,32 @@ describe('isUserActionAllowed', () => {
});
});
describe('determineRequiredAuthString', () => {
const testPerm = auth.UserActions.UserSettingsRead;
test.each([
[true, `${testPerm.permission} permission`],
[false, `${testPerm.fallbackMinimumRoleRequired} role`],
])('RBAC enabled: %s', (rbacEnabled, expected) => {
config.featureToggles.accessControlOnCall = rbacEnabled;
expect(auth.determineRequiredAuthString(testPerm)).toBe(expected);
});
});
describe('generateMissingPermissionMessage', () => {
const testPerm = auth.UserActions.UserSettingsRead;
test.each([
[true, `You are missing the ${testPerm.permission} permission`],
[false, `You are missing the ${testPerm.fallbackMinimumRoleRequired} role`],
])('RBAC enabled: %s', (rbacEnabled, expected) => {
config.featureToggles.accessControlOnCall = rbacEnabled;
expect(auth.generateMissingPermissionMessage(testPerm)).toBe(expected);
});
});
describe('generatePermissionString', () => {
test('it properly builds permission strings with prefixes', () => {
expect(auth.generatePermissionString(auth.Resource.API_KEYS, auth.Action.READ, true)).toEqual(

View file

@ -90,12 +90,24 @@ export const userHasMinimumRequiredRole = (minimumRoleRequired: OrgRole): boolea
*
* As a fallback (second argument), for cases where RBAC is not enabled for a grafana instance, rely on basic roles
*/
export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean => {
if (config.featureToggles.accessControlOnCall) {
return !!contextSrv.user.permissions?.[permission];
}
return userHasMinimumRequiredRole(fallbackMinimumRoleRequired);
};
export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean =>
config.featureToggles.accessControlOnCall
? !!contextSrv.user.permissions?.[permission]
: userHasMinimumRequiredRole(fallbackMinimumRoleRequired);
/**
* Given a `UserAction`, returns the permission or fallback-role, prefixed with "permission" or "role" respectively
* depending on whether or not RBAC is enabled/disabled
*/
export const determineRequiredAuthString = ({ permission, fallbackMinimumRoleRequired }: UserAction): string =>
config.featureToggles.accessControlOnCall ? `${permission} permission` : `${fallbackMinimumRoleRequired} role`;
/**
* Can be used to generate a user-friendly message about which permission is missing. Method is RBAC-aware
* and shows user the missing permission/basic-role depending on whether or not RBAC is enabled/disabled
*/
export const generateMissingPermissionMessage = (permission: UserAction): string =>
`You are missing the ${determineRequiredAuthString(permission)}`;
export const generatePermissionString = (resource: Resource, action: Action, includePrefix: boolean): string =>
`${includePrefix ? `${ONCALL_PERMISSION_PREFIX}.` : ''}${resource}:${action}`;