OnCall plugin use service accounts instead of api keys (#2385)
# What this PR does Changes OnCall plugin to use service accounts and api tokens instead of api keys. API keys will continue to work but if the plugin ever replaces them it will use a service account instead. Previously this was thought to be unnecessary but it was missing the case where the API key was converted to a service account which it could no longer find when searching the `/api/auth/keys` endpoint. That key would not be deleted and it would conflict with a newly created one of the same name. Now the behaviour is as follows: 1. Anytime a new token is needed all API keys and tokens under the service account matching the defined names will be deleted 2. A service account will be created named `sa-autogen-OnCall` if one does not already exist 3. An api token will be created under that service account named `OnCall` ## Which issue(s) this PR fixes #1806 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
This commit is contained in:
parent
8ff5af5e4a
commit
5b009563db
3 changed files with 110 additions and 34 deletions
|
|
@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## v1.3.4 (2023-06-29)
|
||||
|
||||
This version contains just some small cleanup and CI changes 🙂
|
||||
### Changed
|
||||
|
||||
- Change OnCall plugin to use service accounts and api tokens for communicating with backend, by @mderynck ([#2385](https://github.com/grafana/oncall/pull/2385))
|
||||
|
||||
## v1.3.3 (2023-06-28)
|
||||
|
||||
|
|
|
|||
|
|
@ -136,22 +136,75 @@ class PluginState {
|
|||
this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true });
|
||||
|
||||
static readonly KEYS_BASE_URL = '/api/auth/keys';
|
||||
static readonly ONCALL_KEY_NAME = 'OnCall';
|
||||
static readonly SERVICE_ACCOUNTS_BASE_URL = '/api/serviceaccounts';
|
||||
static readonly ONCALL_SERVICE_ACCOUNT_NAME = 'sa-autogen-OnCall';
|
||||
static readonly SERVICE_ACCOUNTS_SEARCH_URL = `${PluginState.SERVICE_ACCOUNTS_BASE_URL}/search?query=${PluginState.ONCALL_SERVICE_ACCOUNT_NAME}`;
|
||||
|
||||
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 getServiceAccount = async () => {
|
||||
const serviceAccounts = await this.grafanaBackend.get(this.SERVICE_ACCOUNTS_SEARCH_URL);
|
||||
return serviceAccounts.serviceAccounts.length > 0 ? serviceAccounts.serviceAccounts[0] : null;
|
||||
};
|
||||
|
||||
static getOrCreateServiceAccount = async () => {
|
||||
const serviceAccount = await this.getServiceAccount();
|
||||
if (serviceAccount) {
|
||||
return serviceAccount;
|
||||
}
|
||||
|
||||
return await this.grafanaBackend.post(this.SERVICE_ACCOUNTS_BASE_URL, {
|
||||
name: this.ONCALL_SERVICE_ACCOUNT_NAME,
|
||||
role: 'Admin',
|
||||
isDisabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
static getTokenFromServiceAccount = async (serviceAccount) => {
|
||||
const tokens = await this.grafanaBackend.get(`${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens`);
|
||||
return tokens.find((key: { id: number; name: string; role: string }) => key.name === PluginState.ONCALL_KEY_NAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* This will satisfy a check for an existing key regardless of if the key is an older api key or under a
|
||||
* service account.
|
||||
*/
|
||||
static getGrafanaToken = async () => {
|
||||
const serviceAccount = await this.getServiceAccount();
|
||||
if (serviceAccount) {
|
||||
return await this.getTokenFromServiceAccount(serviceAccount);
|
||||
}
|
||||
|
||||
const keys = await this.grafanaBackend.get(this.KEYS_BASE_URL);
|
||||
const oncallApiKeys = keys.find(
|
||||
(key: { id: number; name: string; role: string }) => key.name === PluginState.ONCALL_KEY_NAME
|
||||
);
|
||||
if (oncallApiKeys) {
|
||||
return oncallApiKeys;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create service account and api token belonging to it instead of using api keys
|
||||
*/
|
||||
static createGrafanaToken = async () => {
|
||||
const serviceAccount = await this.getOrCreateServiceAccount();
|
||||
const existingToken = await this.getTokenFromServiceAccount(serviceAccount);
|
||||
if (existingToken) {
|
||||
await this.grafanaBackend.delete(
|
||||
`${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens/${existingToken.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const existingKey = await this.getGrafanaToken();
|
||||
if (existingKey) {
|
||||
await this.grafanaBackend.delete(`${this.KEYS_BASE_URL}/${existingKey.id}`);
|
||||
}
|
||||
|
||||
return await this.grafanaBackend.post(this.KEYS_BASE_URL, {
|
||||
name: 'OnCall',
|
||||
return await this.grafanaBackend.post(`${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens`, {
|
||||
name: PluginState.ONCALL_KEY_NAME,
|
||||
role: 'Admin',
|
||||
secondsToLive: null,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -179,38 +179,59 @@ describe('PluginState.updateGrafanaPluginSettings', () => {
|
|||
});
|
||||
|
||||
describe('PluginState.createGrafanaToken', () => {
|
||||
test.each([true, false])('it calls the proper methods - existing key: %s', async (onCallKeyExists) => {
|
||||
const baseUrl = '/api/auth/keys';
|
||||
const onCallKeyId = 12345;
|
||||
const onCallKeyName = 'OnCall';
|
||||
const onCallKey = { name: onCallKeyName, id: onCallKeyId };
|
||||
const existingKeys = [{ name: 'foo', id: 9595 }];
|
||||
const cases = [
|
||||
[true, true, false],
|
||||
[true, false, false],
|
||||
[false, true, true],
|
||||
[false, true, false],
|
||||
[false, false, false],
|
||||
];
|
||||
|
||||
PluginState.grafanaBackend.get = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(onCallKeyExists ? [...existingKeys, onCallKey] : existingKeys);
|
||||
PluginState.grafanaBackend.delete = jest.fn();
|
||||
PluginState.grafanaBackend.post = jest.fn();
|
||||
test.each(cases)(
|
||||
'it calls the proper methods - existing key: %s, existing sa: %s, existing token: %s',
|
||||
async (apiKeyExists, saExists, apiTokenExists) => {
|
||||
const baseUrl = PluginState.KEYS_BASE_URL;
|
||||
const serviceAccountBaseUrl = PluginState.SERVICE_ACCOUNTS_BASE_URL;
|
||||
const apiKeyId = 12345;
|
||||
const apiKeyName = PluginState.ONCALL_KEY_NAME;
|
||||
const apiKey = { name: apiKeyName, id: apiKeyId };
|
||||
const saId = 33333;
|
||||
const serviceAccount = { id: saId };
|
||||
|
||||
await PluginState.createGrafanaToken();
|
||||
PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce(apiKeyExists ? apiKey : null);
|
||||
PluginState.grafanaBackend.delete = jest.fn();
|
||||
PluginState.grafanaBackend.post = jest.fn();
|
||||
|
||||
expect(PluginState.grafanaBackend.get).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.grafanaBackend.get).toHaveBeenCalledWith(baseUrl);
|
||||
PluginState.getServiceAccount = jest.fn().mockReturnValueOnce(saExists ? serviceAccount : null);
|
||||
PluginState.getOrCreateServiceAccount = jest.fn().mockReturnValueOnce(serviceAccount);
|
||||
PluginState.getTokenFromServiceAccount = jest.fn().mockReturnValueOnce(apiTokenExists ? apiKey : null);
|
||||
|
||||
if (onCallKeyExists) {
|
||||
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith(`${baseUrl}/${onCallKeyId}`);
|
||||
} else {
|
||||
expect(PluginState.grafanaBackend.delete).not.toHaveBeenCalled();
|
||||
await PluginState.createGrafanaToken();
|
||||
|
||||
expect(PluginState.getGrafanaToken).toHaveBeenCalledTimes(1);
|
||||
|
||||
if (apiKeyExists) {
|
||||
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith(`${baseUrl}/${apiKey.id}`);
|
||||
} else if (apiTokenExists) {
|
||||
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith(
|
||||
`${serviceAccountBaseUrl}/${serviceAccount.id}/tokens/${apiKey.id}`
|
||||
);
|
||||
} else {
|
||||
expect(PluginState.grafanaBackend.delete).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
expect(PluginState.grafanaBackend.post).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.grafanaBackend.post).toHaveBeenCalledWith(
|
||||
`${serviceAccountBaseUrl}/${serviceAccount.id}/tokens`,
|
||||
{
|
||||
name: apiKeyName,
|
||||
role: 'Admin',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
expect(PluginState.grafanaBackend.post).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.grafanaBackend.post).toHaveBeenCalledWith(baseUrl, {
|
||||
name: onCallKeyName,
|
||||
role: 'Admin',
|
||||
secondsToLive: null,
|
||||
});
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
describe('PluginState.getPluginSyncStatus', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue