Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change permissions used for finishing plugin setup #2242

Merged
merged 11 commits into from
Jun 26, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Change .Values.externalRabbitmq.passwordKey from `password` to `""` (default value `rabbitmq-password`) ([#864](https://github.com/grafana/oncall/pull/864))
- Remove deprecated `permissions` string array from the internal API user serializer by @joeyorlando ([#2269](https://github.com/grafana/oncall/pull/2269))
- Change permissions used during setup to better represent actions being taken by @mderynck ([#2242](https://github.com/grafana/oncall/pull/2242))

### Added

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Button, Label, Legend, LoadingPlaceholder } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
import { OnCallPluginConfigPageProps } from 'types';

import logo from 'img/logo.svg';
import PluginState, { PluginStatusResponseBase } from 'state/plugin';
import { GRAFANA_LICENSE_OSS } from 'utils/consts';

Expand Down Expand Up @@ -228,8 +227,8 @@ const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
{pluginIsConnected ? (
<>
<p>
Plugin is connected! Continue to Grafana OnCall by clicking the{' '}
<img alt="Grafana OnCall Logo" src={logo} width={18} /> icon over there 👈
Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over
there 👈
mderynck marked this conversation as resolved.
Show resolved Hide resolved
</p>
<StatusMessageBlock
text={`Connected to OnCall (${pluginIsConnected.version}, ${pluginIsConnected.license})`}
Expand Down
6 changes: 5 additions & 1 deletion grafana-plugin/src/plugin/PluginSetup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AppRootProps } from 'types';

import logo from 'img/logo.svg';
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
import PluginState from 'state/plugin';
import { useStore } from 'state/useStore';

export type PluginSetupProps = AppRootProps & {
Expand All @@ -34,7 +35,10 @@ const PluginSetupWrapper: FC<PluginSetupWrapperProps> = ({ text, children }) =>

const PluginSetup: FC<PluginSetupProps> = observer(({ InitializedComponent, ...props }) => {
const store = useStore();
const setupPlugin = useCallback(() => store.setupPlugin(props.meta), [props.meta]);
const setupPlugin = useCallback(
() => PluginState.getGrafanaPluginSettings().then((meta) => store.setupPlugin(meta)),
mderynck marked this conversation as resolved.
Show resolved Hide resolved
[]
);

useEffect(() => {
setupPlugin();
Expand Down
46 changes: 35 additions & 11 deletions grafana-plugin/src/state/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,20 @@ class PluginState {
static updateGrafanaPluginSettings = async (data: UpdateGrafanaPluginSettingsProps, enabled = true) =>
this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true });

static createGrafanaToken = async () => {
const baseUrl = '/api/auth/keys';
const keys = await this.grafanaBackend.get(baseUrl);
const existingKey = keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall');
static readonly KEYS_BASE_URL = '/api/auth/keys';

static getGrafanaToken = async () => {
const keys = await this.grafanaBackend.get(this.KEYS_BASE_URL);
return keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall');
};

static createGrafanaToken = async () => {
const existingKey = await this.getGrafanaToken();
if (existingKey) {
await this.grafanaBackend.delete(`${baseUrl}/${existingKey.id}`);
await this.grafanaBackend.delete(`${this.KEYS_BASE_URL}/${existingKey.id}`);
}

return await this.grafanaBackend.post(baseUrl, {
return await this.grafanaBackend.post(this.KEYS_BASE_URL, {
name: 'OnCall',
role: 'Admin',
secondsToLive: null,
Expand Down Expand Up @@ -205,6 +209,15 @@ class PluginState {
onCallApiUrlIsConfiguredThroughEnvVar = false
): Promise<PluginSyncStatusResponse | string> => {
try {
/**
* Allows the plugin config page to repair settings like the app initialization screen if a user deletes
* an API on accident but leaves the plugin settings intact.
*/
const existingKey = await this.getGrafanaToken();
if (!existingKey) {
await this.installPlugin();
}

const startSyncResponse = await makeRequest(`${this.ONCALL_BASE_URL}/sync`, { method: 'POST' });
if (typeof startSyncResponse === 'string') {
// an error occured trying to initiate the sync
Expand Down Expand Up @@ -300,11 +313,22 @@ class PluginState {
return null;
};

static checkIfBackendIsInMaintenanceMode = async (): Promise<string> => {
const response = await makeRequest<PluginIsInMaintenanceModeResponse>('/maintenance-mode-status', {
method: 'GET',
});
return response.currently_undergoing_maintenance_message;
static checkIfBackendIsInMaintenanceMode = async (
onCallApiUrl: string,
onCallApiUrlIsConfiguredThroughEnvVar = false
): Promise<PluginIsInMaintenanceModeResponse | string> => {
try {
return await makeRequest<PluginIsInMaintenanceModeResponse>('/maintenance-mode-status', {
method: 'GET',
});
} catch (e) {
return this.getHumanReadableErrorFromOnCallError(
e,
onCallApiUrl,
'install',
onCallApiUrlIsConfiguredThroughEnvVar
);
}
};
mderynck marked this conversation as resolved.
Show resolved Hide resolved

static checkIfPluginIsConnected = async (
Expand Down
3 changes: 2 additions & 1 deletion grafana-plugin/src/state/plugin/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,10 +663,11 @@ describe('PluginState.checkIfBackendIsInMaintenanceMode', () => {
// mocks
const maintenanceModeMsg = 'asdfljkadsjlfkajsdf';
const mockedResp = { currently_undergoing_maintenance_message: maintenanceModeMsg };
const onCallApiUrl = 'http://hello.com';
makeRequest.mockResolvedValueOnce(mockedResp);

// test
const response = await PluginState.checkIfBackendIsInMaintenanceMode();
const response = await PluginState.checkIfBackendIsInMaintenanceMode(onCallApiUrl);

// assertions
expect(response).toEqual(maintenanceModeMsg);
Expand Down
63 changes: 42 additions & 21 deletions grafana-plugin/src/state/rootBaseStore/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { OrgRole } from '@grafana/data';
import { contextSrv } from 'grafana/app/core/core';
import { action, observable } from 'mobx';
import moment from 'moment-timezone';
import qs from 'query-string';
Expand Down Expand Up @@ -32,7 +34,6 @@ import { UserGroupStore } from 'models/user_group/user_group';
import { makeRequest } from 'network';
import { AppFeature } from 'state/features';
import PluginState from 'state/plugin';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { GRAFANA_LICENSE_OSS } from 'utils/consts';

// ------ Dashboard ------ //
Expand Down Expand Up @@ -162,13 +163,15 @@ export class RootBaseStore {
return this.setupPluginError('🚫 Plugin has not been initialized');
}

const isInMaintenanceMode = await PluginState.checkIfBackendIsInMaintenanceMode();
if (isInMaintenanceMode !== null) {
const maintenanceMode = await PluginState.checkIfBackendIsInMaintenanceMode(this.onCallApiUrl);
if (typeof maintenanceMode === 'string') {
return this.setupPluginError(maintenanceMode);
} else if (maintenanceMode.currently_undergoing_maintenance_message) {
this.currentlyUndergoingMaintenance = true;
return this.setupPluginError(`🚧 ${isInMaintenanceMode} 🚧`);
return this.setupPluginError(`🚧 ${maintenanceMode.currently_undergoing_maintenance_message} 🚧`);
}

// at this point we know the plugin is provionsed
// at this point we know the plugin is provisioned
const pluginConnectionStatus = await PluginState.checkIfPluginIsConnected(this.onCallApiUrl);
if (typeof pluginConnectionStatus === 'string') {
return this.setupPluginError(pluginConnectionStatus);
Expand All @@ -184,22 +187,32 @@ export class RootBaseStore {
if (!allow_signup) {
return this.setupPluginError('🚫 OnCall has temporarily disabled signup of new users. Please try again later.');
}

if (!isUserActionAllowed(UserActions.PluginsInstall)) {
return this.setupPluginError(
'🚫 An Admin in your organization must sign on and setup OnCall before it can be used'
);
}

try {
/**
* this will install AND sync the necessary data
* the sync is done automatically by the /plugin/install OnCall API endpoint
* therefore there is no need to trigger an additional/separate sync, nor poll a status
*/
await PluginState.installPlugin();
} catch (e) {
return this.setupPluginError(PluginState.getHumanReadableErrorFromOnCallError(e, this.onCallApiUrl, 'install'));
const missingPermissions = this.checkMissingSetupPermissions();
if (missingPermissions.length === 0) {
try {
/**
* this will install AND sync the necessary data
* the sync is done automatically by the /plugin/install OnCall API endpoint
* therefore there is no need to trigger an additional/separate sync, nor poll a status
*/
await PluginState.installPlugin();
} catch (e) {
return this.setupPluginError(
PluginState.getHumanReadableErrorFromOnCallError(e, this.onCallApiUrl, 'install')
);
}
} else {
if (contextSrv.accessControlEnabled()) {
return this.setupPluginError(
'🚫 User is missing permission(s) ' +
missingPermissions.join(', ') +
' to setup OnCall before it can be used'
);
} else {
return this.setupPluginError(
'🚫 User with Admin permissions in your organization must sign on and setup OnCall before it can be used'
);
}
}
} else {
const syncDataResponse = await PluginState.syncDataWithOnCall(this.onCallApiUrl);
Expand All @@ -223,6 +236,14 @@ export class RootBaseStore {
this.appLoading = false;
}

checkMissingSetupPermissions() {
const fallback = contextSrv.user.orgRole === OrgRole.Admin && !contextSrv.accessControlEnabled();
const setupRequiredPermissions = ['plugins:write', 'users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'];
return setupRequiredPermissions.filter(function (permission) {
return !contextSrv.hasAccess(permission, fallback);
});
}

hasFeature(feature: string | AppFeature) {
// todo use AppFeature only
return this.features?.[feature];
Expand Down
99 changes: 86 additions & 13 deletions grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { OrgRole } from '@grafana/data';
import { contextSrv } from 'grafana/app/core/core';
import { OnCallAppPluginMeta } from 'types';

import PluginState from 'state/plugin';
import { UserActions, isUserActionAllowed as isUserActionAllowedOriginal } from 'utils/authorization';
import { isUserActionAllowed as isUserActionAllowedOriginal } from 'utils/authorization';

import { RootBaseStore } from './';

jest.mock('state/plugin');
jest.mock('utils/authorization');
jest.mock('grafana/app/core/core', () => ({
contextSrv: {
user: {
orgRole: null,
},
},
}));

const isUserActionAllowed = isUserActionAllowedOriginal as jest.Mock<ReturnType<typeof isUserActionAllowedOriginal>>;

const PluginInstallAction = UserActions.PluginsInstall;

const generatePluginData = (
onCallApiUrl: OnCallAppPluginMeta['jsonData']['onCallApiUrl'] = null
): OnCallAppPluginMeta =>
Expand Down Expand Up @@ -140,6 +147,10 @@ describe('rootBaseStore', () => {
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore();

contextSrv.user.orgRole = OrgRole.Viewer;
contextSrv.accessControlEnabled = jest.fn().mockReturnValue(false);
contextSrv.hasAccess = jest.fn().mockReturnValue(false);

PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null);
PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({
is_user_anonymous: false,
Expand All @@ -159,14 +170,11 @@ describe('rootBaseStore', () => {
expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1);
expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl);

expect(isUserActionAllowed).toHaveBeenCalledTimes(1);
expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction);

expect(PluginState.installPlugin).toHaveBeenCalledTimes(0);

expect(rootBaseStore.appLoading).toBe(false);
expect(rootBaseStore.initializationError).toEqual(
'🚫 An Admin in your organization must sign on and setup OnCall before it can be used'
'🚫 User with Admin permissions in your organization must sign on and setup OnCall before it can be used'
);
});

Expand All @@ -179,6 +187,10 @@ describe('rootBaseStore', () => {
const rootBaseStore = new RootBaseStore();
const mockedLoadCurrentUser = jest.fn();

contextSrv.user.orgRole = OrgRole.Admin;
contextSrv.accessControlEnabled = jest.fn().mockResolvedValueOnce(false);
contextSrv.hasAccess = jest.fn().mockReturnValue(true);

PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null);
PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({
...scenario,
Expand All @@ -198,9 +210,6 @@ describe('rootBaseStore', () => {
expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1);
expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl);

expect(isUserActionAllowed).toHaveBeenCalledTimes(1);
expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction);

expect(PluginState.installPlugin).toHaveBeenCalledTimes(1);
expect(PluginState.installPlugin).toHaveBeenCalledWith();

Expand All @@ -211,13 +220,80 @@ describe('rootBaseStore', () => {
expect(rootBaseStore.initializationError).toBeNull();
});

test.each([
{ role: OrgRole.Admin, missing_permissions: [], expected_result: true },
{ role: OrgRole.Viewer, missing_permissions: [], expected_result: true },
{
role: OrgRole.Admin,
missing_permissions: ['plugins:write', 'users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'],
expected_result: false,
},
{
role: OrgRole.Viewer,
missing_permissions: ['plugins:write', 'users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'],
expected_result: false,
},
])('signup is allowed, accessControlEnabled, various roles and permissions', async (scenario) => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore();
const mockedLoadCurrentUser = jest.fn();

contextSrv.user.orgRole = scenario.role;
contextSrv.accessControlEnabled = jest.fn().mockReturnValue(true);
rootBaseStore.checkMissingSetupPermissions = jest.fn().mockImplementation(() => scenario.missing_permissions);

PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null);
PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({
...scenario,
is_user_anonymous: false,
allow_signup: true,
version: 'asdfasdf',
license: 'asdfasdf',
});
isUserActionAllowed.mockReturnValueOnce(true);
PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null);
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;

// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));

// assertions
expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1);
expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl);

expect(rootBaseStore.appLoading).toBe(false);

if (scenario.expected_result) {
expect(PluginState.installPlugin).toHaveBeenCalledTimes(1);
expect(PluginState.installPlugin).toHaveBeenCalledWith();

expect(mockedLoadCurrentUser).toHaveBeenCalledTimes(1);
expect(mockedLoadCurrentUser).toHaveBeenCalledWith();

expect(rootBaseStore.initializationError).toBeNull();
} else {
expect(PluginState.installPlugin).toHaveBeenCalledTimes(0);

expect(rootBaseStore.initializationError).toEqual(
'🚫 User is missing permission(s) ' +
scenario.missing_permissions.join(', ') +
' to setup OnCall before it can be used'
);
}
});

test('plugin is not installed, signup is allowed, the user is an admin, and plugin installation throws an error', async () => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore();
const installPluginError = new Error('asdasdfasdfasf');
const humanReadableErrorMsg = 'asdfasldkfjaksdjflk';

contextSrv.user.orgRole = OrgRole.Admin;
contextSrv.accessControlEnabled = jest.fn().mockReturnValue(false);
contextSrv.hasAccess = jest.fn().mockReturnValue(true);

PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null);
PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({
is_user_anonymous: false,
Expand All @@ -238,9 +314,6 @@ describe('rootBaseStore', () => {
expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1);
expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl);

expect(isUserActionAllowed).toHaveBeenCalledTimes(1);
expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction);

expect(PluginState.installPlugin).toHaveBeenCalledTimes(1);
expect(PluginState.installPlugin).toHaveBeenCalledWith();

Expand Down
Loading