From 5b009563db8b098c97e680f1484d3640ddf7134e Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 29 Jun 2023 05:37:28 -0600 Subject: [PATCH] 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 --- CHANGELOG.md | 4 +- grafana-plugin/src/state/plugin/index.ts | 65 ++++++++++++++-- .../src/state/plugin/plugin.test.ts | 75 ++++++++++++------- 3 files changed, 110 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f4bbed..56aa5211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/grafana-plugin/src/state/plugin/index.ts b/grafana-plugin/src/state/plugin/index.ts index 7a63925c..a9154ebe 100644 --- a/grafana-plugin/src/state/plugin/index.ts +++ b/grafana-plugin/src/state/plugin/index.ts @@ -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, }); }; diff --git a/grafana-plugin/src/state/plugin/plugin.test.ts b/grafana-plugin/src/state/plugin/plugin.test.ts index 24292a9d..98fab74e 100644 --- a/grafana-plugin/src/state/plugin/plugin.test.ts +++ b/grafana-plugin/src/state/plugin/plugin.test.ts @@ -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', () => {