Skip to content

Commit 5b00956

Browse files
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 <[email protected]>
1 parent 8ff5af5 commit 5b00956

File tree

3 files changed

+111
-35
lines changed

3 files changed

+111
-35
lines changed

CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## v1.3.4 (2023-06-29)
1111

12-
This version contains just some small cleanup and CI changes 🙂
12+
### Changed
13+
14+
- Change OnCall plugin to use service accounts and api tokens for communicating with backend, by @mderynck ([#2385](https://github.com/grafana/oncall/pull/2385))
1315

1416
## v1.3.3 (2023-06-28)
1517

grafana-plugin/src/state/plugin/index.ts

+57-4
Original file line numberDiff line numberDiff line change
@@ -136,22 +136,75 @@ class PluginState {
136136
this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true });
137137

138138
static readonly KEYS_BASE_URL = '/api/auth/keys';
139+
static readonly ONCALL_KEY_NAME = 'OnCall';
140+
static readonly SERVICE_ACCOUNTS_BASE_URL = '/api/serviceaccounts';
141+
static readonly ONCALL_SERVICE_ACCOUNT_NAME = 'sa-autogen-OnCall';
142+
static readonly SERVICE_ACCOUNTS_SEARCH_URL = `${PluginState.SERVICE_ACCOUNTS_BASE_URL}/search?query=${PluginState.ONCALL_SERVICE_ACCOUNT_NAME}`;
143+
144+
static getServiceAccount = async () => {
145+
const serviceAccounts = await this.grafanaBackend.get(this.SERVICE_ACCOUNTS_SEARCH_URL);
146+
return serviceAccounts.serviceAccounts.length > 0 ? serviceAccounts.serviceAccounts[0] : null;
147+
};
148+
149+
static getOrCreateServiceAccount = async () => {
150+
const serviceAccount = await this.getServiceAccount();
151+
if (serviceAccount) {
152+
return serviceAccount;
153+
}
139154

155+
return await this.grafanaBackend.post(this.SERVICE_ACCOUNTS_BASE_URL, {
156+
name: this.ONCALL_SERVICE_ACCOUNT_NAME,
157+
role: 'Admin',
158+
isDisabled: false,
159+
});
160+
};
161+
162+
static getTokenFromServiceAccount = async (serviceAccount) => {
163+
const tokens = await this.grafanaBackend.get(`${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens`);
164+
return tokens.find((key: { id: number; name: string; role: string }) => key.name === PluginState.ONCALL_KEY_NAME);
165+
};
166+
167+
/**
168+
* This will satisfy a check for an existing key regardless of if the key is an older api key or under a
169+
* service account.
170+
*/
140171
static getGrafanaToken = async () => {
172+
const serviceAccount = await this.getServiceAccount();
173+
if (serviceAccount) {
174+
return await this.getTokenFromServiceAccount(serviceAccount);
175+
}
176+
141177
const keys = await this.grafanaBackend.get(this.KEYS_BASE_URL);
142-
return keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall');
178+
const oncallApiKeys = keys.find(
179+
(key: { id: number; name: string; role: string }) => key.name === PluginState.ONCALL_KEY_NAME
180+
);
181+
if (oncallApiKeys) {
182+
return oncallApiKeys;
183+
}
184+
185+
return null;
143186
};
144187

188+
/**
189+
* Create service account and api token belonging to it instead of using api keys
190+
*/
145191
static createGrafanaToken = async () => {
192+
const serviceAccount = await this.getOrCreateServiceAccount();
193+
const existingToken = await this.getTokenFromServiceAccount(serviceAccount);
194+
if (existingToken) {
195+
await this.grafanaBackend.delete(
196+
`${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens/${existingToken.id}`
197+
);
198+
}
199+
146200
const existingKey = await this.getGrafanaToken();
147201
if (existingKey) {
148202
await this.grafanaBackend.delete(`${this.KEYS_BASE_URL}/${existingKey.id}`);
149203
}
150204

151-
return await this.grafanaBackend.post(this.KEYS_BASE_URL, {
152-
name: 'OnCall',
205+
return await this.grafanaBackend.post(`${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens`, {
206+
name: PluginState.ONCALL_KEY_NAME,
153207
role: 'Admin',
154-
secondsToLive: null,
155208
});
156209
};
157210

grafana-plugin/src/state/plugin/plugin.test.ts

+51-30
Original file line numberDiff line numberDiff line change
@@ -179,38 +179,59 @@ describe('PluginState.updateGrafanaPluginSettings', () => {
179179
});
180180

181181
describe('PluginState.createGrafanaToken', () => {
182-
test.each([true, false])('it calls the proper methods - existing key: %s', async (onCallKeyExists) => {
183-
const baseUrl = '/api/auth/keys';
184-
const onCallKeyId = 12345;
185-
const onCallKeyName = 'OnCall';
186-
const onCallKey = { name: onCallKeyName, id: onCallKeyId };
187-
const existingKeys = [{ name: 'foo', id: 9595 }];
188-
189-
PluginState.grafanaBackend.get = jest
190-
.fn()
191-
.mockResolvedValueOnce(onCallKeyExists ? [...existingKeys, onCallKey] : existingKeys);
192-
PluginState.grafanaBackend.delete = jest.fn();
193-
PluginState.grafanaBackend.post = jest.fn();
194-
195-
await PluginState.createGrafanaToken();
196-
197-
expect(PluginState.grafanaBackend.get).toHaveBeenCalledTimes(1);
198-
expect(PluginState.grafanaBackend.get).toHaveBeenCalledWith(baseUrl);
182+
const cases = [
183+
[true, true, false],
184+
[true, false, false],
185+
[false, true, true],
186+
[false, true, false],
187+
[false, false, false],
188+
];
189+
190+
test.each(cases)(
191+
'it calls the proper methods - existing key: %s, existing sa: %s, existing token: %s',
192+
async (apiKeyExists, saExists, apiTokenExists) => {
193+
const baseUrl = PluginState.KEYS_BASE_URL;
194+
const serviceAccountBaseUrl = PluginState.SERVICE_ACCOUNTS_BASE_URL;
195+
const apiKeyId = 12345;
196+
const apiKeyName = PluginState.ONCALL_KEY_NAME;
197+
const apiKey = { name: apiKeyName, id: apiKeyId };
198+
const saId = 33333;
199+
const serviceAccount = { id: saId };
200+
201+
PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce(apiKeyExists ? apiKey : null);
202+
PluginState.grafanaBackend.delete = jest.fn();
203+
PluginState.grafanaBackend.post = jest.fn();
204+
205+
PluginState.getServiceAccount = jest.fn().mockReturnValueOnce(saExists ? serviceAccount : null);
206+
PluginState.getOrCreateServiceAccount = jest.fn().mockReturnValueOnce(serviceAccount);
207+
PluginState.getTokenFromServiceAccount = jest.fn().mockReturnValueOnce(apiTokenExists ? apiKey : null);
208+
209+
await PluginState.createGrafanaToken();
210+
211+
expect(PluginState.getGrafanaToken).toHaveBeenCalledTimes(1);
212+
213+
if (apiKeyExists) {
214+
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1);
215+
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith(`${baseUrl}/${apiKey.id}`);
216+
} else if (apiTokenExists) {
217+
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1);
218+
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith(
219+
`${serviceAccountBaseUrl}/${serviceAccount.id}/tokens/${apiKey.id}`
220+
);
221+
} else {
222+
expect(PluginState.grafanaBackend.delete).not.toHaveBeenCalled();
223+
}
199224

200-
if (onCallKeyExists) {
201-
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1);
202-
expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith(`${baseUrl}/${onCallKeyId}`);
203-
} else {
204-
expect(PluginState.grafanaBackend.delete).not.toHaveBeenCalled();
225+
expect(PluginState.grafanaBackend.post).toHaveBeenCalledTimes(1);
226+
expect(PluginState.grafanaBackend.post).toHaveBeenCalledWith(
227+
`${serviceAccountBaseUrl}/${serviceAccount.id}/tokens`,
228+
{
229+
name: apiKeyName,
230+
role: 'Admin',
231+
}
232+
);
205233
}
206-
207-
expect(PluginState.grafanaBackend.post).toHaveBeenCalledTimes(1);
208-
expect(PluginState.grafanaBackend.post).toHaveBeenCalledWith(baseUrl, {
209-
name: onCallKeyName,
210-
role: 'Admin',
211-
secondsToLive: null,
212-
});
213-
});
234+
);
214235
});
215236

216237
describe('PluginState.getPluginSyncStatus', () => {

0 commit comments

Comments
 (0)