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:
Michael Derynck 2023-06-29 05:37:28 -06:00 committed by GitHub
parent 8ff5af5e4a
commit 5b009563db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 110 additions and 34 deletions

View file

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

View file

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

View file

@ -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', () => {